Merge branch 'stable-3.8' into stable-3.9

* stable-3.8:
  gerrit-config.txt: fix wording

Release-Notes: skip
Change-Id: Ie5fe811a178dc293a3207712e67d93629375253e
diff --git a/.bazelrc b/.bazelrc
index 7c7d98b..a8f1210 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -3,49 +3,49 @@
 build --action_env=PATH
 build --disk_cache=~/.gerritcodereview/bazel-cache/cas
 
-# Define configuration using remotejdk_11, executes using remotejdk_11 or local_jdk
-build:build_shared --java_language_version=11
-build:build_shared --java_runtime_version=remotejdk_11
-build:build_shared --tool_java_language_version=11
-build:build_shared --tool_java_runtime_version=remotejdk_11
+# Define configuration using remotejdk_17, executes using remotejdk_17 or local_jdk
+build:build_shared --java_language_version=17
+build:build_shared --java_runtime_version=remotejdk_17
+build:build_shared --tool_java_language_version=17
+build:build_shared --tool_java_runtime_version=remotejdk_17
 
-# Builds using remotejdk_11, executes using remotejdk_11 or local_jdk
+# Builds using remotejdk_17, executes using remotejdk_17 or local_jdk
 # Avoid warnings for non default configurations:
 # build --config=build_shared
-build --java_language_version=11
-build --java_runtime_version=remotejdk_11
-build --tool_java_language_version=11
-build --tool_java_runtime_version=remotejdk_11
+build --java_language_version=17
+build --java_runtime_version=remotejdk_17
+build --tool_java_language_version=17
+build --tool_java_runtime_version=remotejdk_17
 
-# Builds and executes on Google GCP RBE using remotejdk_11
+# Builds and executes on Google GCP RBE using remotejdk_17
 build:remote --config=config_gcp
 build:remote --config=build_shared
 
 # Define remote configuration alias
 build:remote_gcp --config=remote
 
-# Builds and executes on BuildBuddy RBE using remotejdk_11
+# Builds and executes on BuildBuddy RBE using remotejdk_17
 build:remote_bb --config=config_bb
 build:remote_bb --config=build_shared
 
-# Define configuration using remotejdk_17, executes using remotejdk_17 or local_jdk
-build:build_java17_shared --java_language_version=17
-build:build_java17_shared --java_runtime_version=remotejdk_17
-build:build_java17_shared --tool_java_language_version=17
-build:build_java17_shared --tool_java_runtime_version=remotejdk_17
+# Define configuration using remotejdk_11, executes using remotejdk_11 or local_jdk
+build:build_java11_shared --java_language_version=11
+build:build_java11_shared --java_runtime_version=remotejdk_11
+build:build_java11_shared --tool_java_language_version=11
+build:build_java11_shared --tool_java_runtime_version=remotejdk_11
 
-build:java17 --config=build_java17_shared
+build:java11 --config=build_java11_shared
 
-# Builds and executes on Google GCP RBE using remotejdk_17
-build:remote17 --config=config_gcp
-build:remote17 --config=build_java17_shared
+# Builds and executes on Google GCP RBE using remotejdk_11
+build:remote11 --config=config_gcp
+build:remote11 --config=build_java11_shared
 
-# Define remote17 configuration alias
-build:remote17_gcp --config=remote17
+# Define remote11 configuration alias
+build:remote11_gcp --config=remote11
 
-# Builds and executes on BuildBuddy RBE using remotejdk_17
-build:remote17_bb --config=config_bb
-build:remote17_bb --config=build_java17_shared
+# Builds and executes on BuildBuddy RBE using remotejdk_11
+build:remote11_bb --config=config_bb
+build:remote11_bb --config=build_java11_shared
 
 # Enable strict_action_env flag to. For more information on this feature see
 # https://groups.google.com/forum/#!topic/bazel-discuss/_VmRfMyyHBk.
@@ -58,5 +58,9 @@
 
 test --build_tests_only
 test --test_output=errors
+# This option is the default for Bazel 7 that is used on master since
+# change Ie7cb3003d, so this additional config should be removed when
+# merging to master.
+test --incompatible_sandbox_hermetic_tmp
 
 import %workspace%/tools/remote-bazelrc
diff --git a/.gitmodules b/.gitmodules
index 6217b4d..7579477 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,7 @@
+[submodule "modules/java-prettify"]
+	path = modules/java-prettify
+	url = ../java-prettify
+
 [submodule "modules/jgit"]
 	path = modules/jgit
 	url = ../jgit
diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs
index 09ce63b..ba37f19 100644
--- a/.settings/org.eclipse.jdt.core.prefs
+++ b/.settings/org.eclipse.jdt.core.prefs
@@ -1,4 +1,5 @@
 eclipse.preferences.version=1
+org.eclipse.jdt.core.builder.annotationPath.allLocations=disabled
 org.eclipse.jdt.core.compiler.annotation.inheritNullAnnotations=disabled
 org.eclipse.jdt.core.compiler.annotation.missingNonNullByDefaultAnnotation=ignore
 org.eclipse.jdt.core.compiler.annotation.nonnull=org.eclipse.jdt.annotation.NonNull
@@ -10,9 +11,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=11
+org.eclipse.jdt.core.compiler.codegen.targetPlatform=17
 org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve
-org.eclipse.jdt.core.compiler.compliance=11
+org.eclipse.jdt.core.compiler.compliance=17
 org.eclipse.jdt.core.compiler.debug.lineNumber=generate
 org.eclipse.jdt.core.compiler.debug.localVariable=generate
 org.eclipse.jdt.core.compiler.debug.sourceFile=generate
@@ -102,7 +103,7 @@
 org.eclipse.jdt.core.compiler.problem.uncheckedTypeOperation=warning
 org.eclipse.jdt.core.compiler.problem.unclosedCloseable=warning
 org.eclipse.jdt.core.compiler.problem.undocumentedEmptyBlock=ignore
-org.eclipse.jdt.core.compiler.problem.unhandledWarningToken=warning
+org.eclipse.jdt.core.compiler.problem.unhandledWarningToken=ignore
 org.eclipse.jdt.core.compiler.problem.unlikelyCollectionMethodArgumentType=warning
 org.eclipse.jdt.core.compiler.problem.unlikelyCollectionMethodArgumentTypeStrict=disabled
 org.eclipse.jdt.core.compiler.problem.unlikelyEqualsArgumentType=warning
@@ -129,4 +130,4 @@
 org.eclipse.jdt.core.compiler.problem.varargsArgumentNeedCast=warning
 org.eclipse.jdt.core.compiler.processAnnotations=enabled
 org.eclipse.jdt.core.compiler.release=enabled
-org.eclipse.jdt.core.compiler.source=11
+org.eclipse.jdt.core.compiler.source=17
diff --git a/.zuul.yaml b/.zuul.yaml
index d6dbc34..e0e92fa 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -7,6 +7,7 @@
       This adds required projects needed for all Gerrit-related builds
       (i.e., builds of Gerrit itself or plugins) on this branch.
     required-projects:
+      - java-prettify
       - jgit
 
 - job:
diff --git a/BUILD b/BUILD
index 984fd955..0c10d76 100644
--- a/BUILD
+++ b/BUILD
@@ -3,13 +3,6 @@
 
 package(default_visibility = ["//visibility:public"])
 
-config_setting(
-    name = "java17",
-    values = {
-        "java_language_version": "17",
-    },
-)
-
 genrule(
     name = "gen_version",
     outs = ["version.txt"],
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index cf89982..19a19dd 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -906,7 +906,7 @@
 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
+Must be assigned on the target branch ref (i.e. on 'refs/heads/\*', not on
 'refs/for/*').
 
 [[category_delete_own_changes]]
@@ -951,6 +951,20 @@
 can always edit or remove hashtags (even without having the `Edit Hashtags`
 access right assigned).
 
+
+[[category_edit_custom_keyed_values]]
+=== Edit Custom Keyed Values
+
+This category permits users to add or remove
+custom keyed values on a change that is uploaded for review. Custom Keyed Values
+are used by plugins to store extra data. They are not surfaced in the UI, unless
+a plugin explicitly does so.
+
+The change owner and site administrators can always edit or remove custom
+keyed values (even without having the `Edit Custom Keyed Values` access right
+assigned).
+
+
 [[example_roles]]
 == Examples of typical roles in a project
 
@@ -970,7 +984,7 @@
 
 Suggested access rights to grant:
 
-* xref:category_read[`Read`] on 'refs/heads/\*'
+* xref:category_read[`Read`] on 'refs/heads/*'
 * xref:category_push[`Push`] to 'refs/for/refs/heads/*'
 * link:config-labels.html#label_Code-Review[`Code-Review`] with range '-1' to '+1' for 'refs/heads/*'
 
@@ -998,7 +1012,7 @@
 
 Suggested access rights to grant:
 
-* xref:category_read[`Read`] on 'refs/heads/\*'
+* xref:category_read[`Read`] on 'refs/heads/*'
 * xref:category_push[`Push`] to 'refs/for/refs/heads/*'
 * xref:category_push_merge[`Push merge commit`] to 'refs/for/refs/heads/*'
 * xref:category_forge_author[`Forge Author Identity`] to 'refs/heads/*'
@@ -1053,7 +1067,7 @@
 
 Suggested access rights to grant, that won't block changes:
 
-* xref:category_read[`Read`] on 'refs/heads/\*'
+* xref:category_read[`Read`] on 'refs/heads/*'
 * link:config-labels.html#label_Code-Review[`Label: Code-Review`] with range '-1' to '0' for 'refs/heads/*'
 * link:config-labels.html#label_Verified[`Label: Verified`] with range '0' to '+1' for 'refs/heads/*'
 
@@ -1076,7 +1090,7 @@
 * <<examples_developer,Developer rights>>
 * <<category_push,`Push`>> to 'refs/heads/*'
 * <<category_push_merge,`Push merge commit`>> to 'refs/heads/*'
-* <<category_forge_committer,`Forge Committer Identity`>> to 'refs/for/refs/heads/*'
+* <<category_forge_committer,`Forge Committer Identity`>> to 'refs/heads/*'
 * <<category_create,`Create Reference`>> to 'refs/heads/*'
 * <<category_create_annotated,`Create Annotated Tag`>> to 'refs/tags/*'
 
diff --git a/Documentation/backend_licenses.txt b/Documentation/backend_licenses.txt
index 586406f..9ea01cf 100755
--- a/Documentation/backend_licenses.txt
+++ b/Documentation/backend_licenses.txt
@@ -1113,31 +1113,7 @@
 [[flexmark]]
 flexmark
 
-* flexmark
-* flexmark-ext-abbreviation
-* flexmark-ext-anchorlink
-* flexmark-ext-autolink
-* flexmark-ext-definition
-* flexmark-ext-emoji
-* flexmark-ext-escaped-character
-* flexmark-ext-footnotes
-* flexmark-ext-gfm-issues
-* flexmark-ext-gfm-strikethrough
-* flexmark-ext-gfm-tables
-* flexmark-ext-gfm-tasklist
-* flexmark-ext-gfm-users
-* flexmark-ext-ins
-* flexmark-ext-jekyll-front-matter
-* flexmark-ext-superscript
-* flexmark-ext-tables
-* flexmark-ext-toc
-* flexmark-ext-typographic
-* flexmark-ext-wikilink
-* flexmark-ext-yaml-front-matter
-* flexmark-formatter
-* flexmark-html-parser
-* flexmark-profile-pegdown
-* flexmark-util
+* flexmark-all-lib
 
 [[flexmark_license]]
 ----
diff --git a/Documentation/cmd-show-caches.txt b/Documentation/cmd-show-caches.txt
index 050118b..65f05b1 100644
--- a/Documentation/cmd-show-caches.txt
+++ b/Documentation/cmd-show-caches.txt
@@ -7,7 +7,6 @@
 [verse]
 --
 _ssh_ -p <port> <host> _gerrit show-caches_
-  [--gc]
   [--show-jvm]
 --
 
@@ -15,10 +14,6 @@
 Display statistics about the size and hit ratio of in-memory caches.
 
 == OPTIONS
---gc::
-	Request Java garbage collection before displaying information
-	about the Java memory heap.
-
 --show-jvm::
 	List the name and version of the Java virtual machine, host
 	operating system, and other details about the environment
diff --git a/Documentation/cmd-stream-events.txt b/Documentation/cmd-stream-events.txt
index 2456662..e34ba6a 100644
--- a/Documentation/cmd-stream-events.txt
+++ b/Documentation/cmd-stream-events.txt
@@ -207,6 +207,24 @@
 eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
 created.
 
+=== Batch Ref Updated
+
+Sent when a reference is updated in a git repository. A `batch-ref-updated` event contains all refs
+updated in a single operation. Thus, the refUpdated-field contains a list of 1 (in case of a `RefUpdate`)
+to n (in case of a `BatchRefUpdate`) ref-updates, i.e. listeners of `batch-ref-updated` events will be
+notified about every ref update and not just about batch ref updates.
+You may want to listen to individual or batch ref-updates, but not both of them. Listening to both
+`batch-ref-updates` and `ref-updates` events will cause processing the same ref updates twice.
+
+type:: "batch-ref-updated"
+
+submitter:: link:json.html#account[account attribute]
+
+refUpdates:: list of link:json.html#refUpdates[refUpdate attributes]
+
+eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
+created.
+
 === Reviewer Added
 
 Sent when a reviewer is added to a change.
diff --git a/Documentation/cmd-version.txt b/Documentation/cmd-version.txt
index cdfc779..3e53678 100644
--- a/Documentation/cmd-version.txt
+++ b/Documentation/cmd-version.txt
@@ -7,6 +7,8 @@
 [verse]
 --
 _ssh_ -p <port> <host> _gerrit version_
+  [--verbose | -v]
+  [--json]
 --
 
 == DESCRIPTION
@@ -31,6 +33,14 @@
 == SCRIPTING
 This command is intended to be used in scripts.
 
+== OPTIONS
+--verbose::
+-v::
+  Verbose output, include also the NoteDb version and the version of each index.
+
+--json::
+  Json output format. Assumes verbose output.
+
 == EXAMPLES
 
 ----
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 8d53764..46a171d 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -886,6 +886,7 @@
 +
 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
@@ -1416,6 +1417,13 @@
 +
 The default limit is 16kiB.
 
+[[change.topicLimit]]change.topicLimit::
++
+Maximum allowed number of changes with the same topic. 0 or negative values
+mean "unlimited".
++
+By default 5,000.
+
 [[change.cumulativeCommentSizeLimit]]change.cumulativeCommentSizeLimit::
 +
 Maximum allowed size in characters of all comments (including robot comments)
@@ -1440,10 +1448,21 @@
 [[change.maxFiles]]change.maxFiles::
 +
 Maximum number of files allowed per change. Larger changes are rejected and must
-be split up.
+be split up. For merge changes we are comparing against the auto-merge commit,
+so we allow large merges, if they merge cleanly.
 +
 By default 100,000.
 
+[[change.maxFileSizeDownload]]change.maxFileSizeDownload::
++
+The link:rest-api-changes.html#get-content[GetContent] and
+link:rest-api-changes.html#get-safe-content[DownloadContent] REST APIs will
+refuse to load files larger than this limit (in bytes). 0 or negative values
+mean "unlimited".
+
++
+By default 0 (unlimited).
+
 [[change.maxPatchSets]]change.maxPatchSets::
 +
 Maximum number of patch sets allowed per change. If this is insufficient,
@@ -1545,6 +1564,21 @@
 +
 By default true.
 
+[[change.propagateSubmitRequirementErrors]]change.propagateSubmitRequirementErrors::
++
+If a SubmitRequirement evaluation for a given change results in an
+ERROR status, abort the REST response with an HTTP 500 error.
++
+The ERROR status can occur if a SubmitRequirement uses a
+plugin-provided predicate (and the plugin is not available), due to
+bugs, or due to bypassing the validation that normally happens when
+updating `refs/meta/config`.
++
+Enabling this flag  makes gerrit unusuable under such conditions, so
+it is generally not recommended. However, this makes the
+application-specific ERROR status into a generic HTTP error, and can
+thus be acted on by automated deployment and monitoring infrastructure.
+
 [[change.robotCommentSizeLimit]]change.robotCommentSizeLimit::
 +
 Maximum allowed size in characters of a robot comment. Robot comments which
@@ -1649,6 +1683,23 @@
 +
 Default is 5 minutes.
 
+[[change.diff3ConflictView]]change.diff3ConflictView::
++
+Use the diff3 formatter for merge commits with conflicts. With diff3 when
+the conflicts are shown in the "Auto Merge" view, the base section from the
+common parents will be shown as well.
+This setting takes effect when generating the automerge, which happens on upload.
+Changing the setting leaves existing changes unaffected.
++
+Default is false.
+
+[[change.maxFileSizeDiff]]change.maxFileSizeDiff::
++
+The threshold of file sizes in megabytes beyond which a
+link:rest-api-changes.html#get-diff[file diff] request will fail.
++
+If not set or set to zero, no limits are applied on file sizes.
+
 [[change.skipCurrentRulesEvaluationOnClosedChanges]]
 +
 If false, Gerrit will always take latest project configuration to
@@ -1731,6 +1782,54 @@
 link:#schedule-configuration-examples[Schedule examples] can be found
 in the link:#schedule-configuration[Schedule Configuration] section.
 
+[[attentionSet]]
+=== Section attentionSet
+
+This section allows to configure readding of change owners and schedules them to
+run periodically.
+
+[[attentionSet.readdAfter]]attentionSet.readdAfter::
++
+Period of inactivity after which open no-WIP/private changes should have change owner
+added to attention-set automatically (if they are not already).
++
+By default `0`, never readd change owner.
++
+[WARNING] Auto-readding change owners may confuse/annoy users. When
+enabling this, make sure to choose a reasonably large grace period and
+inform users in advance.
++
+The following suffixes are supported to define the time unit:
++
+* `d, day, days`
+* `w, week, weeks` (`1 week` is treated as `7 days`)
+* `mon, month, months` (`1 month` is treated as `30 days`)
+* `y, year, years` (`1 year` is treated as `365 days`)
+
+[[attentionSet.readdMessage]]attentionSet.readdMessage::
++
+Attention-set message that should be shown as reason when an change owner is readded.
++
+'${URL}' can be used as a placeholder for the Gerrit web URL.
++
+By default "Owner readded to attention-set due to inactivity, see
+${URL}Documentation/user-attention-set.html#auto-readd-owner\n\n
+If you do not want to be readded to the attention-set when the timer has counted down.
+Set this change as WIP or private.".
+
+[[attentionSet.startTime]]attentionSet.startTime::
++
+The link:#schedule-configuration-startTime[start time] for running
+readd owner to attention-set.
+
+[[attentionSet.interval]]attentionSet.interval::
++
+The link:#schedule-configuration-interval[interval] for running
+readd owner to attention-set.
+
+link:#schedule-configuration-examples[Schedule examples] can be found
+in the link:#schedule-configuration[Schedule Configuration] section.
+
 [[commentlink]]
 === Section commentlink
 
@@ -1756,7 +1855,7 @@
 ----
 [commentlink "changeid"]
   match = (I[0-9a-f]{8,40})
-  link = "#/q/$1"
+  link = "/q/$1"
 
 [commentlink "bugzilla"]
   match = "(^|\\s)(bug\\s+#?)(\\d+)($|\\s)"
@@ -2412,7 +2511,7 @@
 [gerrit]
   installIndexModule = com.google.gerrit.elasticsearch.ElasticIndexModule
 ----
-+
+
 [[gerrit.installModule]]gerrit.installModule::
 +
 Repeatable list of class name of additional Guice modules to load at
@@ -2746,6 +2845,21 @@
 [[groups]]
 === Section groups
 
+[[groups.auditLog.ignoreRecordsFromUnidentifiedUsers]]groups.auditLog.ignoreRecordsFromUnidentifiedUsers::
++
+Controls whether AuditLogReader should ignore commits created by unidentified users.
+If true, then AuditLogReader ignores commits in the refs/groups/* made by unidentified users (i.e.
+when the author of a commit can't be parsed as account id).
++
+The current version of Gerrit writes identified users as authors for new refs/groups/* commits.
+However, some old versions used a server identity as the author (e.g. "Gerrit Code Review
+<server@googlesource.com>") for such commits. Such string can't be converted to account id but
+usually the commit shouldn't be ignored.
++
+By default, false.
++
+Setting it to true may lead to some unexpected results in audit log and must be set carefully.
+
 [[groups.includeExternalUsersInRegisteredUsersGroup]]groups.includeExternalUsersInRegisteredUsersGroup::
 +
 Controls whether external users (these are users we have sufficient
@@ -3340,6 +3454,15 @@
 +
 Defaults to true.
 
+[[index.excludeProjectFromChangeReindex]]index.excludeProjectFromChangeReindex::
++
+A list of projects that will be excluded from reindexing. This can be used
+to exclude projects which are expensive to reindex to prioritize the other
+projects.
++
+Excluded projects can later be reindexed by for example using the
+link:cmd-index-changes-in-project.html[index changes in project command].
+
 [[index.paginationType]]index.paginationType::
 +
 The pagination type to use when index queries are repeated to
@@ -3373,6 +3496,15 @@
 +
 Defaults to `OFFSET`.
 
+[[index.defaultLimit]]index.defaultLimit::
++
+Default limit, if the user does not provide a limit. If this is not set or set
+to 0, then index queries are executed with the maximum permitted limit for the
+user, which may be really high and cause too much load on the index. Thus
+setting this default limit to something smaller like 100 allows you to control
+the load, while not taking away any permission from the user. If the user
+provides a limit themselves, then `defaultLimit` is ignored.
+
 [[index.maxLimit]]index.maxLimit::
 +
 Maximum limit to allow for search queries. Requesting results above this
@@ -3454,6 +3586,16 @@
 +
 Defaults to false.
 
+[[index.indexChangesAsync]]index.indexChangesAsync::
++
+On BatchUpdate, do not await indexing completion before returning the request
+to the user (WEB_BROWSER requests only).
+This has an advantage of faster UI (because indexing latency does not contribute
+to the write request latency) and disadvantage that the indexing result might not be
+immediately available after the write request.
++
+Defaults to false.
+
 [[index.scheduledIndexer]]
 ==== Subsection index.scheduledIndexer
 
@@ -3658,6 +3800,37 @@
 +
 By default, true.
 
+[[event.stream-events.enableRefUpdatedEvents]]event.stream-events.enableRefUpdatedEvents::
++
+Enable streaming of `ref-updated` event which represents a single ref update operation.
+Batch ref updates are represented as a series of `ref-updated` events.
+This allows event listeners to react on a ref update.
+Please consider switching to `batch-ref-updated` event which provides better control on grouping and
+preserving order of the ref updates.
++
+By default, true.
+
+[[event.stream-events.enableBatchRefUpdatedEvents]]event.stream-events.enableBatchRefUpdatedEvents::
++
+Enable streaming of `batch-ref-updated` event which represents group of
+refs updated during a single batch ref update operation.
+Single ref updates are also streamed as a `batch-ref-updated` events with a single ref specified.
+This allows event listeners to react on all ref updated events and disable individual `ref-updated`
+events by setting <<event.stream-events.enableRefUpdatedEvents, event.stream-events.enableRefUpdatedEvents>> to false.
++
+By default, false.
+
+[[event.stream-events.enableDraftCommentEvents]]event.stream-events.enableDraftCommentEvents::
++
+Enable streaming of `ref-updated` events for `refs/draft-comments` refs.
+Enable this flag in case listeners in your system are supposed to react on draft operations.
++
+NOTE: Due to the nature of drafts, the amount of `ref-updated` events created on draft operations could be high.
+The extra amount of events depends on the usage pattern of the installation. It is worth evaluating
+the amount of extra events produced before enabling this flag by counting the calls to the draft APIs.
++
+By default, false.
+
 [[experiments]]
 === Section experiments
 
@@ -3854,11 +4027,12 @@
 example to join given name and surname together, use the pattern
 `${givenName} ${SN}`.
 +
-If set, users will be unable to modify their full name field, as
-Gerrit will populate it only from the LDAP data.
-+
 Default is `displayName` for FreeIPA and RFC 2307 servers,
 and `${givenName} ${sn}` for Active Directory.
++
+A non-empty or default value prevents users from modifying their full
+name field.  To allow edits to the full name field, set to the empty
+string.
 
 [[ldap.accountEmailAddress]]ldap.accountEmailAddress::
 +
@@ -3997,6 +4171,9 @@
 All users must be a member of this group to allow account creation or
 authentication.
 +
+For example, setting to `ldap/gerritaccess` limits account creation or
+authentication to members of the ldap group `gerritaccess`.
++
 Setting mandatoryGroup implies enabling of `ldap.fetchMemberOfEagerly`
 +
 By default, unset.
@@ -4601,6 +4778,35 @@
 If no keys are specified, web-of-trust checks are disabled. This is the
 default behavior.
 
+[[receive.enableChangeIdLinkFooters]]receive.enableChangeIdLinkFooters::
++
+Enables a `Link` footer to be used as an alternative change ID footer.
++
+In some projects it may be desirable for the footer to contain a link to
+the Gerrit review page so that it is convenient to access the review
+page starting from the commit message. The `Link` footer is a standard
+footer used for inserting links in the commit message (e.g. used by the
+Linux kernel).
++
+This option makes Gerrit interoperate well with `Link` footers. If
+change ID `Link` footers are enabled Gerrit recognizes footers of the
+form:
++
+----
+  Link: https://<host>/id/<change-ID>
+----
++
+Example:
+----
+  Link: https://gerrit-review.googlesource.com/id/I78e884a944cedb5144f661a057e4829c8f84e933
+----
++
+For Gerrit to recognize the change ID, the part of the URL before the
+'/id/' part must match with the link:#gerrit.canonicalWebUrl[canonical
+web URL].
++
+Default is `true`.
+
 [[repository]]
 === Section repository
 
@@ -5547,18 +5753,6 @@
 +
 By default, false.
 
-[[tracing.exportPerformanceMetrics]]tracing.exportPerformanceMetrics::
-+
-Whether to export performance metrics.
-+
-Performace logged when link:#tracing.performanceLogging[`performanceLogging`] is
-enabled, can be exported as metrics.
-+
-NOTE: Since the payload returned could be of tens of thousands metrics,
-assess the latency of the metrics endpoint before enabling this option.
-+
-By default, false.
-
 [[tracing.traceid]]
 ==== Subsection tracing.<trace-id>
 
@@ -5595,12 +5789,42 @@
 should not be enabled even if they match
 link:#tracing.traceid.requestUriPattern[requestUriPattern].
 Request URIs are only available for REST requests. Request URIs never include
-the '/a' prefix.
+the '/a' prefix and don't contain the query string with the request parameters.
 +
 May be specified multiple times.
 +
 By default, unset (no request URIs are excluded).
 
+[[tracing.traceid.requestQueryStringPattern]]tracing.<trace-id>.requestQueryStringPattern::
++
+Regular expression to match request query strings for which request tracing
+should be enabled. The query string is the portion of the URL that contains
+the request parameters.
++
+May be specified multiple times.
++
+Example:
+----
+  requestQueryStringPattern = .*limit=.*
+----
++
+By default, unset (all request query strings are matched).
+
+[[tracing.traceid.headerPattern]]tracing.<trace-id>.headerPattern::
++
+Regular expression to match headers for which request tracing should be
+enabled. The regular expression is matched against the headers in the
+format '<header-name>=<header-value>'.
++
+May be specified multiple times.
++
+Example:
+----
+  requestQueryStringPattern = User-Agent=foo-.*
+----
++
+By default, unset (all headers are matched).
+
 [[tracing.traceid.account]]tracing.<trace-id>.account::
 +
 Account ID of an account for which request tracing should be always
diff --git a/Documentation/config-groups.txt b/Documentation/config-groups.txt
index 4abb223..40f64da 100644
--- a/Documentation/config-groups.txt
+++ b/Documentation/config-groups.txt
@@ -93,10 +93,12 @@
 
 == Pushing to group refs
 
-Validation on push for changes to the group ref is not implemented, so
-pushes are rejected. Pushes that bypass Gerrit should be avoided since
-the names, IDs and UUIDs must be internally consistent between all the
-branches involved. In addition, group references should not be created
+Users can push changes to `refs/for/refs/groups/*`, but submit is rejected
+for changes which update group files (i.e. group.config, members, subgroups).
+It is possible for users to upload and submit changes on the named destination
+or named query files in a group ref. Pushes that bypass Gerrit should be
+avoided since the names, IDs and UUIDs must be internally consistent between
+all the branches involved. In addition, group references should not be created
 or deleted manually either. If you attempt any of these actions
 anyway, don't forget to link:rest-api-groups.html#index-group[Index
 Group] reindex the affected groups manually.
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 25fe9f3..187cd0f 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -69,8 +69,8 @@
 
 The link:#project-section[+project+ section] appears once per project.
 
-The link:#access-section[+access+ section] appears once per reference pattern,
-such as `+refs/*+` or `+refs/heads/*+`.  Only one access section per pattern is
+The link:#access-subsection[+access+ section] appears once per reference pattern,
+such as `+refs/*+` or `+refs/heads/*+`. Only one access section per pattern is
 allowed.
 
 The link:#receive-section[+receive+ section] appears once per project.
@@ -318,7 +318,9 @@
 The submit section includes configuration of project-specific
 submit settings:
 
-[[content_merge]]submit.mergeContent::
+[[content_merge]]
+
+[[submit.mergeContent]]submit.mergeContent::
 +
 Defines whether Gerrit will try to do a content merge when a path conflict
 occurs while submitting a change.
@@ -483,7 +485,7 @@
 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
+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`
@@ -636,8 +638,17 @@
 [[access-section]]
 === Access section
 
-Each +access+ section includes a reference and access rights connected
-to groups.  Each group listed must exist in the link:#file-groups[+groups+ file].
+[[access.inheritFrom]]access.inheritFrom::
++
+Name of the parent project from which access rights are inherited.
++
+If not set, access rights are inherited from the `All-Projects` root project.
+
+[[access-subsection]]
+==== Access subsection
+
++access+ subsections for references connect access rights to groups. Each group
+listed must exist in the link:#file-groups[+groups+ file].
 
 Please refer to the
 link:access-control.html#access_categories[Access Categories]
diff --git a/Documentation/config-submit-requirements.txt b/Documentation/config-submit-requirements.txt
index fb12ff3..1bcda63 100644
--- a/Documentation/config-submit-requirements.txt
+++ b/Documentation/config-submit-requirements.txt
@@ -127,7 +127,7 @@
 link:#submit_requirement_submittable_if[submittableIf] expression evaluates to
 true or not.
 
-* `BYPASSED`
+* `FORCED`
 +
 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.
diff --git a/Documentation/config-themes.txt b/Documentation/config-themes.txt
index 73cfc55..7cf61e4 100644
--- a/Documentation/config-themes.txt
+++ b/Documentation/config-themes.txt
@@ -41,8 +41,14 @@
 
 Static image files can also be served from `'$site_path'/static`,
 and may be referenced in `GerritSite{Header,Footer}.html`
-or `GerritSite.css` by the relative URL `static/$name`
-(e.g. `static/logo.png`).
+or `GerritSite.css`.  For example, `GerritSiteHeader.html` may
+display a company logo like so:
+
+```
+<div>
+  <img src="/static/logo.png" alt="Our Cool Logo" />
+</div>
+```
 
 To simplify security management, files are only served from
 `'$site_path'/static`.  Subdirectories are explicitly forbidden from
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index c3a5c90d..2306cf9 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -35,8 +35,14 @@
 link:https://github.com/bazelbuild/bazelisk[Bazelisk,role=external,window=_blank] is a version
 manager for link:https://bazel.build/[Bazel,role=external,window=_blank], similar to how `nvm`
 manages `npm` versions. It takes care of downloading and installing Bazel itself, so you don't have
-to worry about using the correct version of Bazel. Bazelisk can be installed in different
-ways: link:https://docs.bazel.build/install-bazelisk.html[Install,role=external,window=_blank]
+to worry about using the correct version of Bazel. One particular advantage to
+using Bazelisk is that you can jump between different versions of Gerrit and not
+worry about which version of Bazel you need.
+
+Bazelisk can be installed in different ways:
+link:https://docs.bazel.build/install-bazelisk.html[Bazelisk Installation,role=external,window=_blank].
+To execute the correct version of Bazel using Bazelisk you simply replace
+the `bazel` command with `bazelisk`.
 
 [[java]]
 === Java
@@ -54,7 +60,7 @@
 To build Gerrit with Java 11 language level, run:
 
 ```
-  $ bazel build :release
+  $ bazelisk build --config=java11 :release
 ```
 
 [[java-17]]
@@ -63,13 +69,13 @@
 Java 17 is supported. To build Gerrit with Java 17, run:
 
 ```
-  $ bazel build --config=java17 :release
+  $ bazelisk build :release
 ```
 
 To run the tests with Java 17, run:
 
 ```
-  $ bazel test --config=java17 //...
+  $ bazelisk test //...
 ```
 
 === Node.js and npm packages
@@ -83,7 +89,7 @@
 To build the Gerrit web application:
 
 ----
-  bazel build gerrit
+  bazelisk build gerrit
 ----
 
 The output executable WAR will be placed in:
@@ -99,7 +105,7 @@
 core plugins and documentation:
 
 ----
-  bazel build release
+  bazelisk build release
 ----
 
 The output executable WAR will be placed in:
@@ -113,7 +119,7 @@
 To build Gerrit in headless mode, i.e. without the Gerrit UI:
 
 ----
-  bazel build headless
+  bazelisk build headless
 ----
 
 The output executable WAR will be placed in:
@@ -127,7 +133,7 @@
 To build the extension, plugin and acceptance-framework JAR files:
 
 ----
-  bazel build api
+  bazelisk build api
 ----
 
 The output archive that contains Java binaries, Java sources and
@@ -153,7 +159,7 @@
 === Plugins
 
 ----
-  bazel build plugins:core
+  bazelisk build plugins:core
 ----
 
 The output JAR files for individual plugins will be placed in:
@@ -171,7 +177,7 @@
 To build a specific plugin:
 
 ----
-  bazel build plugins/<name>
+  bazelisk build plugins/<name>
 ----
 
 The output JAR file will be be placed in:
@@ -216,7 +222,7 @@
 To build only the documentation for testing or static hosting:
 
 ----
-  bazel build Documentation:searchfree
+  bazelisk build Documentation:searchfree
 ----
 
 The html files will be bundled into `searchfree.zip` in this location:
@@ -241,7 +247,7 @@
 To generate HTML files skipping the zip archiving:
 
 ----
-  bazel build Documentation
+  bazelisk build Documentation
 ----
 
 And open `bazel-bin/Documentation/index.html`.
@@ -249,7 +255,7 @@
 To build the Gerrit executable WAR with the documentation included:
 
 ----
-  bazel build withdocs
+  bazelisk build withdocs
 ----
 
 The WAR file will be placed in:
@@ -261,7 +267,7 @@
 Alternatively, one can generate the documentation as flat files:
 
 ----
-  bazel build Documentation:Documentation
+  bazelisk build Documentation:Documentation
 ----
 
 The html, css, js files are placed in:
@@ -273,64 +279,23 @@
 [[tests]]
 == Running Unit Tests
 
-----
-  bazel test --build_tests_only //...
-----
-
-Debugging tests:
+Bazel BUILD files define test targets for Gerrit. You can run all declared
+test targets with:
 
 ----
-  bazel test --test_output=streamed --test_filter=com.gerrit.TestClass.testMethod testTarget
+  bazelisk test --build_tests_only //...
 ----
 
-Debug test example:
+[[testgroups]]
+=== Running Test Groups
+
+To run one or more specific labeled groups of tests:
 
 ----
-  bazel test --test_output=streamed --test_filter=com.google.gerrit.acceptance.api.change.ChangeIT.getAmbiguous //javatests/com/google/gerrit/acceptance/api/change:api_change
+  bazelisk test --test_tag_filters=api,git //...
 ----
 
-To run a specific test group, e.g. the rest-account test group:
-
-----
-  bazel test //javatests/com/google/gerrit/acceptance/rest/account:rest_account
-----
-
-To run only tests that do not use SSH:
-
-----
-  bazel test --test_env=GERRIT_USE_SSH=NO //...
-----
-
-To exclude tests that have been marked as flaky:
-
-----
-  bazel test --test_tag_filters=-flaky //...
-----
-
-To exclude tests that require very recent git client version:
-
-----
-  bazel test --test_tag_filters=-git-protocol-v2 //...
-----
-
-To ignore cached test results:
-
-----
-  bazel test --cache_test_results=NO //...
-----
-
-To run one or more specific groups of tests:
-
-----
-  bazel test --test_tag_filters=api,git //...
-----
-
-To run the tests against a specific index backend (LUCENE, FAKE):
-----
-  bazel test --test_env=GERRIT_INDEX_TYPE=LUCENE //...
-----
-
-The following values are currently supported for the group name:
+The following label values are currently supported for the group name:
 
 * annotation
 * api
@@ -344,11 +309,90 @@
 * server
 * ssh
 
+We can also select tests within a specific BUILD target group. For example
+`javatests/com/google/gerrit/acceptance/rest/account/BUILD` declares a
+rest_account test target group:
+
+----
+  bazelisk test //javatests/com/google/gerrit/acceptance/rest/account:rest_account
+----
+
+[[debugtests]]
+=== Debugging Tests
+
+To debug specific tests you will need to select the test target containing
+that test then use `--test_filter` to select the specific test you want.
+This `--test_filter` is a regex and can be used to select multiple tests
+out of the target:
+
+----
+  bazelisk test --test_output=streamed --test_filter=com.gerrit.TestClass.testMethod testTarget
+----
+
+For example `javatests/com/google/gerrit/acceptance/api/change/BUILD`
+defines a test target group for every `*IT.java` file in the directory.
+We can execute the single `getAmbiguous()` test found in ChangeIT.java using
+this `--test_filter` and target:
+
+----
+  bazelisk test --test_output=streamed \
+    --test_filter=com.google.gerrit.acceptance.api.change.ChangeIT.getAmbiguous \
+    //javatests/com/google/gerrit/acceptance/api/change:ChangeIT
+----
+
+[[additionaltestfiltering]]
+=== Additional Test Filtering
+
+To run only tests that do not use SSH:
+
+----
+  bazelisk test --test_env=GERRIT_USE_SSH=NO //...
+----
+
+To exclude tests that have been marked as flaky:
+
+----
+  bazelisk test --test_tag_filters=-flaky //...
+----
+
+To exclude tests that require very recent git client version:
+
+----
+  bazelisk test --test_tag_filters=-git-protocol-v2 //...
+----
+
+To run the tests against a specific index backend (LUCENE, FAKE):
+----
+  bazelisk test --test_env=GERRIT_INDEX_TYPE=LUCENE //...
+----
+
 Bazel itself supports a multitude of ways to
-link:https://docs.bazel.build/versions/master/guide.html#specifying-targets-to-build[specify targets,role=external,window=_blank]
+link:https://bazel.build/run/build#specifying-build-targets[specify targets,role=external,window=_blank]
 for fine-grained test selection that can be combined with many of the examples
 above.
 
+[[testcaching]]
+=== Test Caching
+
+By default Bazel caches test results and will not reexecute tests unless they
+or their dependencies have been modified. To ignore cached test results and
+force the tests to rerun:
+
+----
+  bazelisk test --cache_test_results=NO //...
+----
+
+[[plugintests]]
+=== Running Plugin Tests
+
+Running tests for Gerrit plugins follows the process above. From within the
+Gerrit project root with the desired plugins checked out into `plugins/` we
+execute Bazel with the appropriate target:
+
+----
+  bazelisk test //plugins/replication/...
+----
+
 [[debugging-tests]]
 == Debugging Unit Tests
 In some cases it may be necessary to debug a test while running it in bazel. For example, when we
@@ -358,7 +402,7 @@
 Example:
 [source,bash]
 ----
-  bazel test --java_debug --test_tag_filters=delete-project //...
+  bazelisk test --java_debug --test_tag_filters=delete-project //...
   ...
   Listening for transport dt_socket at address: 5005
   ...
@@ -377,9 +421,9 @@
 `GERRIT_LOG_LEVEL=debug` environment variable:
 
 ----
-  bazel test --test_filter=com.google.gerrit.server.notedb.ChangeNotesTest \
-  --test_env=GERRIT_LOG_LEVEL=debug \
-  javatests/com/google/gerrit/server:server_tests
+  bazelisk test --test_filter=com.google.gerrit.server.notedb.ChangeNotesTest \
+    --test_env=GERRIT_LOG_LEVEL=debug \
+    javatests/com/google/gerrit/server:server_tests
 ----
 
 The log results can be found in:
@@ -393,7 +437,7 @@
 subsequent builds to run without network access:
 
 ----
-  bazel fetch //...
+  bazelisk fetch //...
 ----
 
 When downloading from behind a proxy (which is common in some corporate
@@ -498,7 +542,7 @@
 
 The `downloaded-artifacts` cache can be relocated by setting the
 `GERRIT_CACHE_HOME` environment variable. The other two can be adjusted with
-`bazel build` options `--repository_cache` and `--disk_cache` respectively.
+`bazelisk build` options `--repository_cache` and `--disk_cache` respectively.
 
 Currently none of these caches have a maximum size limit. See
 link:https://github.com/bazelbuild/bazel/issues/5139[this bazel issue,role=external,window=_blank] for
@@ -560,7 +604,7 @@
 ----
 # Add to ui_npm. Other packages.json can be updated in the same way
 cd $gerrit_repo/polygerrit-ui/app
-bazel run @nodejs//:yarn add $package
+bazelisk run @yarn//:yarn add $package
 ----
 
 Update the `polygerrit-ui/app/node_modules_licenses/licenses.ts` file. You should add licenses
@@ -590,7 +634,7 @@
 === Update NPM Binaries
 To update a NPM binary the same actions as for a new one must be done (check licenses,
 update `licenses.ts` file, etc...). The only difference is a command to install a package: instead
-of `bazel run @nodejs//:yarn add $package` you should run the `bazel run @nodejs//:yarn upgrade ...`
+of `bazelisk run @yarn//:yarn add $package` you should run the `bazelisk run @yarn//:yarn upgrade ...`
 command with correct arguments. You can find the list of arguments in the
 link:https://classic.yarnpkg.com/en/docs/cli/upgrade/[yarn upgrade doc,role=external,window=_blank].
 
@@ -657,7 +701,7 @@
 To use RBE, execute
 
 ```
-bazel test --config=remote \
+bazelisk test --config=remote \
     --remote_instance_name=projects/${PROJECT}/instances/default_instance \
     javatests/...
 ```
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 3c4e9ea..42edc1f 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -127,6 +127,74 @@
 In this runtime, only the module designated by `Gerrit-BatchModule` is
 enabled, not `Gerrit-SysModule`.
 
+=== Cross-Plugin communication
+
+A plugin can optionally declare an API to be used by other plugins.
+
+---
+Gerrit-ApiModule: tld.example.project.APIClassName
+---
+
+Notably, when injecting 'DynamicItem,' 'DynamicSet,' or 'DynamicMap' defined by
+the API, a plugin can register new concrete implementations or replace existing
+ones.
+However, these are not the only classes available for consumption; API plugins
+can also define and provide interfaces and concrete classes for other plugins.
+
+This enables plugins to influence other plugins by customizing or extending the
+their behaviour.
+
+*Gotchas and Limitations*:
+
+- A `plugin A` depending on a `plugin B` (declaring a `Gerrit-ApiModule`),
+  should include `plugin B` as a `neverlink` library in its BUILD file, as
+  follows:
+
+  ```
+  java_library(
+      name = "gerrit-pluginB-neverlink",
+      neverlink = True,
+      exports = ["//plugins/pluginB"],
+  )
+  ```
+
+  The reason is that the `plugin B` dependency should not be included as a
+  shaded jar of the plugin: Gerrit will load the dependency dynamically at
+  runtime instead of packaging it in the consumer plugin fat jar.
+
+- Removing/renaming the plugin jar that defines the classes declared in the
+  `Gerrit-ApiModule` is currently not supported.
+  The behaviour in this case is unpredictable and depends on the specifics of
+  the classes involved.
+
+- An API plugin cannot depend on another API plugin.
+
+*Gerrit CI and Validation*:
+
+To build this as part of the gerrit-ci the plugin Api (in our example,
+`plugin B`) needs to be added as a dependency via the `extra-plugins` property.
+For example:
+
+```
+ project:
+    name: plugin-A
+    jobs:
+      - 'plugin-{name}-bazel-{branch}':
+          extra-plugins: 'plugin-B'
+          branch:
+            - master
+```
+
+Additionally, for `plugin A` to be verified by the gerrit-ci, the `Jenkinsfile`
+also needs to be inclusive of the API dependency, as such:
+
+```
+pluginPipeline(
+  formatCheckId: 'gerritforge:plugin-A-format-3852e64366bb37d13b8baf8af9b15cfd38eb9227',
+  buildCheckId: 'gerritforge:plugin-A-3852e64366bb37d13b8baf8af9b15cfd38eb9227',
+  extraPlugins: ['plugin-B'])
+```
+
 [[plugin_name]]
 === Plugin Name
 
@@ -457,6 +525,10 @@
 +
 Update of the change secondary index
 
+* `com.google.gerrit.server.extensions.events.CustomKeyedValueValidationListener`:
++
+Updates to custom keyed values
+
 * `com.google.gerrit.server.extensions.events.AccountIndexedListener`:
 +
 Update of the account secondary index
@@ -2213,15 +2285,18 @@
 @Listen
 public class MyWeblinkPlugin implements PatchSetWebLink {
   private String name = "MyLink";
-  private String placeHolderUrlProjectCommit = "http://my.tool.com/project=%s/commit=%s";
+  private String placeHolderUrlProjectCommit =
+  "http://my.tool.com/project=%s/commit=%s/changeKey=%s/numericChangeId=%s";
   private String imageUrl = "http://placehold.it/16x16.gif";
 
   @Override
   public WebLinkInfo getPatchSetWebLink(String projectName, String commit,
-   String commitMessage, String branchName) {
+   String commitMessage, String branchName, String changeKey, int
+   numericChangeId) {
     return new WebLinkInfo(name,
         imageUrl,
-        String.format(placeHolderUrlProjectCommit, project, commit));
+        String.format(placeHolderUrlProjectCommit, project, commit, changeKey,
+	numericChangeId));
   }
 }
 ----
@@ -3033,6 +3108,43 @@
 `com.google.gerrit.server.RequestListener` is an extension point that is
 invoked each time the server executes a request from a user.
 
+[[custom-keyed-values]]
+== Custom Keyed values
+
+It is possible to associate custom keyed values with a change. This is a map
+from string to string, allowing for the storage of small keys and values. An
+example would be for storing an associated workspace with the given change.
+
+As an example:
+```
+  private void setWorkspace(ChangeResource rsrc, String workspaceId)
+      throws RestApiException, UpdateException {
+    try (RefUpdateContext pluginCtx = RefUpdateContext.open(PLUGIN);
+        RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION);
+        BatchUpdate bu =
+            updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
+      SetCustomKeyedValuesOp op =
+          customKeyedValuesFactory.create(
+              new CustomKeyedValuesInput(ImmutableMap.of("workspace", workspaceId)));
+      bu.addOp(rsrc.getId(), op);
+      bu.execute();
+    }
+  }
+```
+
+These custom-keyed-values can be fetched by passing the option `o=CUSTOM_KEYED_VALUES`
+to a change details fetch.
+
+[[diff-validators]]
+== Diff Validators
+
+`com.google.gerrit.server.patch.DiffValidator` is an extension point that is
+invoked after the file diff is computed through the
+link:rest-api-changes.html#get-diff[Get Diff] REST endpoint.
+
+Implementors can write logic to validate the diff before it's returned on the
+API.
+
 == SEE ALSO
 
 * link:pg-plugin-dev.html[JavaScript Plugin API]
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
index 175a159..8b7ba4b 100644
--- a/Documentation/dev-processes.txt
+++ b/Documentation/dev-processes.txt
@@ -53,15 +53,14 @@
 === Election of non-Google steering committee members
 
 The election of the non-Google steering committee members happens once
-a year in May. Non-Google link:dev-roles.html#maintainer[maintainers]
+a year in June. Non-Google link:dev-roles.html#maintainer[maintainers]
 can nominate themselves by posting an informal application on the
 non-public mailto:gerritcodereview-community-managers@googlegroups.com[
-community manager mailing list] by end of April (deadline for 2020
-is Thu 30th of April EOD).
+community manager mailing list] when the call for nominations is sent to
+the maintainers list by a community manager.
 
 The list with all candidates will be published at the beginning of the
-voting period (for 2020 the start of the voting is planned for Mon 4th
-of May).
+voting period.
 
 Keeping the candidates private during the nomination phase and
 publishing all candidates at once only at the start of the voting
@@ -83,7 +82,7 @@
 happens by posting on the
 mailto:gerritcodereview-maintainers@googlegroups.com[maintainer mailing
 list]. The voting period is 14 calendar days from the start of the
-voting (for 2020 the voting period ends on Mon 18th May EOD).
+voting.
 
 Google maintainers do not take part in this vote, because Google
 already has dedicated seats in the steering committee (see section
diff --git a/Documentation/dev-roles.txt b/Documentation/dev-roles.txt
index f3a81e7..36fd46e 100644
--- a/Documentation/dev-roles.txt
+++ b/Documentation/dev-roles.txt
@@ -329,10 +329,10 @@
 This is a group that remains private between the individual community
 member and community managers.
 
-The community managers should be a pair or trio that shares the work:
+The community managers should be at least a pair that shares the work:
 
 * One Googler that is appointed by Google.
-* One or two non-Googlers, elected by the community if there are more
+* One or more non-Googlers, elected by the community if there are more
   than two candidates. If there is no candidate, we only have the one
   community manager from Google.
 
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-normal-edits.png b/Documentation/images/user-review-ui-side-by-side-diff-normal-edits.png
new file mode 100644
index 0000000..f89a905
--- /dev/null
+++ b/Documentation/images/user-review-ui-side-by-side-diff-normal-edits.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-rebase-edits.png b/Documentation/images/user-review-ui-side-by-side-diff-rebase-edits.png
new file mode 100644
index 0000000..7edd688
--- /dev/null
+++ b/Documentation/images/user-review-ui-side-by-side-diff-rebase-edits.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-rebase.png b/Documentation/images/user-review-ui-side-by-side-diff-rebase.png
new file mode 100644
index 0000000..3a47f8c
--- /dev/null
+++ b/Documentation/images/user-review-ui-side-by-side-diff-rebase.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-squash.png b/Documentation/images/user-review-ui-side-by-side-diff-squash.png
new file mode 100644
index 0000000..6119742
--- /dev/null
+++ b/Documentation/images/user-review-ui-side-by-side-diff-squash.png
Binary files differ
diff --git a/Documentation/intro-project-owner.txt b/Documentation/intro-project-owner.txt
index f13bc22..97b58af 100644
--- a/Documentation/intro-project-owner.txt
+++ b/Documentation/intro-project-owner.txt
@@ -125,7 +125,7 @@
 and link:access-control.html#references_magic[magic refs].
 
 Gerrit only supports tags that are reachable by any ref not owned by
-Gerrit. This includes branches (refs/heads/*) or custom ref namespaces
+Gerrit. This includes branches (refs/heads/\*) or custom ref namespaces
 (refs/my-company/*). Tagging a change ref is not supported.
 When filtering tags by visibility, Gerrit performs a reachability check
 and will present the user ony with tags that are reachable by any ref
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index 4642247..7825e050 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -282,7 +282,7 @@
 a dependency between the changes in Gerrit and each change can only be
 applied if all its predecessor are applied as well. Dependencies
 between changes can be seen from the
-link:user-review-ui.html#related-changes-tab[Related Changes] tab on
+link:user-review-ui.html#related-changes[Related Changes] tab on
 the change screen.
 
 [[watch]]
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index c032c36..8b6049e 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -285,6 +285,7 @@
 [[Lit]]
 Lit
 
+* @lit-labs/ssr-dom-shim
 * @lit/reactive-element
 * lit
 * lit-element
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 2d5ab1d..6c3c459 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -63,6 +63,7 @@
 * guice:guice-assistedinject
 * guice:guice-library
 * guice:guice-servlet
+* guice:jakarta-inject
 * guice:javax_inject
 * httpcomponents:httpclient
 * httpcomponents:httpcore
@@ -99,6 +100,7 @@
 * javaewah
 * jsr305
 * mime-util
+* roaringbitmap
 * servlet-api
 * servlet-api-without-neverlink
 * soy
@@ -1082,6 +1084,7 @@
 [[bouncycastle]]
 bouncycastle
 
+* bouncycastle:bcpg
 * bouncycastle:bcpg-neverlink
 * bouncycastle:bcpkix-neverlink
 * bouncycastle:bcprov-neverlink
@@ -1116,31 +1119,7 @@
 [[flexmark]]
 flexmark
 
-* flexmark
-* flexmark-ext-abbreviation
-* flexmark-ext-anchorlink
-* flexmark-ext-autolink
-* flexmark-ext-definition
-* flexmark-ext-emoji
-* flexmark-ext-escaped-character
-* flexmark-ext-footnotes
-* flexmark-ext-gfm-issues
-* flexmark-ext-gfm-strikethrough
-* flexmark-ext-gfm-tables
-* flexmark-ext-gfm-tasklist
-* flexmark-ext-gfm-users
-* flexmark-ext-ins
-* flexmark-ext-jekyll-front-matter
-* flexmark-ext-superscript
-* flexmark-ext-tables
-* flexmark-ext-toc
-* flexmark-ext-typographic
-* flexmark-ext-wikilink
-* flexmark-ext-yaml-front-matter
-* flexmark-formatter
-* flexmark-html-parser
-* flexmark-profile-pegdown
-* flexmark-util
+* flexmark-all-lib
 
 [[flexmark_license]]
 ----
@@ -3189,6 +3168,7 @@
 [[Lit]]
 Lit
 
+* @lit-labs/ssr-dom-shim
 * @lit/reactive-element
 * lit
 * lit-element
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index a8c2ec1..67f0565 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.8.5:
+link:https://gerrit-releases.storage.googleapis.com/index.html[Gerrit Code Review: Releases,role=external,window=_blank]. The steps below install Gerrit 3.9.4:
 
 ....
-wget https://gerrit-releases.storage.googleapis.com/gerrit-3.8.5.war
+wget https://gerrit-releases.storage.googleapis.com/gerrit-3.9.4.war
 ....
 
 NOTE: To build and install Gerrit from the source files, see
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 8b21ca2..df0cc42b 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -73,30 +73,6 @@
 ** `cancellation_type`:
    The cancellation type (graceful or forceful).
 
-[[performance]]
-=== Performance
-
-* `performance/operations`: Latency of performing operations
-** `operation_name`:
-   The operation that was performed.
-** `request`:
-   The request for which the operation was performed (format = '<request-type>
-   <redacted-request-uri>').
-** `plugin`:
-   The name of the plugin that performed the operation.
-* `performance/operations_count`: Number of performed operations
-** `operation_name`:
-   The operation that was performed.
-** `request`:
-   The request for which the operation was performed (format = '<request-type>
-   <redacted-request-uri>').
-** `plugin`:
-   The name of the plugin that performed the operation.
-
-Performance metrics can be enabled via the
-link:config.gerrit.html#tracing.exportPerformanceMetrics[`tracing.exportPerformanceMetrics`]
-setting.
-
 === Pushes
 
 * `receivecommits/changes`: histogram of number of changes processed in a single
@@ -303,6 +279,9 @@
    view implementation class
 * `http/server/rest_api/change_json/to_change_info_latency`: Latency for
   toChangeInfo invocations in ChangeJson.
+* `http/server/rest_api/change_json/to_change_info_latency/parent_data_computation`:
+   Latency for computing parent data information in toRevisionInfo invocations
+   in RevisionJson. See link:rest-api-changes.html#parent-info[ParentInfo].
 * `http/server/rest_api/change_json/to_change_infos_latency`: Latency for
   toChangeInfos invocations in ChangeJson.
 * `http/server/rest_api/change_json/format_query_results_latency`: Latency for
diff --git a/Documentation/pg-plugin-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
index 0429f91..04e1b96 100644
--- a/Documentation/pg-plugin-endpoints.txt
+++ b/Documentation/pg-plugin-endpoints.txt
@@ -43,6 +43,11 @@
 
 The following endpoints are available to plugins.
 
+=== auth-link
+The `auth-link` extension point is located in the top right corner of anonymous
+pages. The purpose is to improve user experience for custom OAuth providers by
+providing custom components and/or visual feedback of authentication progress.
+
 === banner
 The `banner` extension point is located at the top of all pages. The purpose
 is to allow plugins to show outage information and important announcements to
diff --git a/Documentation/repository-maintenance.txt b/Documentation/repository-maintenance.txt
index 4bf84b5..61fb6a4 100644
--- a/Documentation/repository-maintenance.txt
+++ b/Documentation/repository-maintenance.txt
@@ -106,7 +106,8 @@
 existing pack files from the `objects/pack` directory into the
 `preserved` directory right before calling the real Git command. This
 approach will then behave similarly to `jgit gc` with respect to
-preserving pack files.
+preserving pack files. An implementation is available
+link:https://gerrit.googlesource.com/gerrit/+/refs/heads/master/contrib/git-gc-preserve[here].
 
 GERRIT
 ------
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 7646777..9ecef3f 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -186,6 +186,27 @@
   }
 ----
 
+[[delete-account]]
+=== Delete Account
+--
+'DELETE /accounts/link:#account-id[\{account-id\}]'
+--
+
+Deletes the given account.
+
+Currently only supporting self deletion (regardless of the way
+link:#account-id[\{account-id\}] is provided).
+
+.Request
+----
+  DELETE /accounts/self HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
 [[get-detail]]
 === Get Account Details
 --
@@ -1293,6 +1314,7 @@
     "publish_comments_on_push": true,
     "work_in_progress_by_default": true,
     "allow_browser_notifications": true,
+    "diff_page_sidebar": "plugin-foo",
     "default_base_for_merges": "FIRST_PARENT",
     "my": [
       {
@@ -1346,6 +1368,7 @@
     "disable_keyboard_shortcuts": true,
     "disable_token_highlighting": true,
     "allow_browser_notifications": false,
+    "diff_page_sidebar": "NONE",
     "diff_view": "SIDE_BY_SIDE",
     "mute_common_path_prefixes": true,
     "my": [
@@ -2693,6 +2716,10 @@
 inline edit feature.
 |`allow_browser_notifications`  |not set if `false`|
 Whether to prompt user to enable browser notification in browser.
+|`diff_page_sidebar`            |optional|
+String indicating which sidebar should be open on the diff page. Set to "NONE"
+if no sidebars should be open. Plugin-supplied sidebars will be prefixed with
+"plugin-".
 |`my`                           ||
 The menu items of the `MY` top menu as a list of
 link:rest-api-config.html#top-menu-item-info[TopMenuItemInfo] entities.
@@ -2764,6 +2791,10 @@
 inline edit feature.
 |`allow_browser_notifications`  |not set if `false`|
 Whether to prompt user to enable browser notification in browser.
+|`diff_page_sidebar`            |optional|
+String indicating which sidebar should be open on the diff page. Set to "NONE"
+if no sidebars should be open. Plugin-supplied sidebars will be prefixed with
+"plugin-".
 |`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 c1451ec..a56766e 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -161,6 +161,15 @@
 are filtered out. REST requests with the skip-visibility option are rejected when the current
 user doesn't have the ADMINISTRATE_SERVER capability.
 
+The `allow-incomplete-results` query parameter can be used. This is a boolean
+parameter that can optionally be set to true. If set, the server can tolerate
+handling faulty records when parsed from the change index, for example if a
+field contained a value of a wrong format. For faulty records, the server
+will return a canonical empty record where only the fields {project, branch,
+change_id, _number, owner} are set and the subject will be set to
+"\*\**ERROR***". All other fields will be empty.
+Note that the handling of this parameter is up to the index implementation.
+
 Clients are allowed to specify more than one query by setting the `q`
 parameter multiple times. In this case the result is an array of
 arrays, one per query in the same order the queries were given in.
@@ -379,6 +388,11 @@
   as link:#tracking-id-info[TrackingIdInfo].
 --
 
+[[custom-keyed-values]]
+--
+* `CUSTOM_KEYED_VALUES`: include the custom key-value map
+--
+
 [[star]]
 --
 * `STAR`: include the `starred` field in
@@ -386,6 +400,14 @@
    by the current user or not.
 --
 
+[[parents-data]]
+--
+* `PARENTS`: include the `parents_data` field in
+   link:#revision-info[RevisionInfo], which provides information of whether the
+   parent commit of this revision, e.g. if it's merged in the target branch
+   and whether it points to a patch-set of another change.
+--
+
 .Request
 ----
   GET /changes/?q=97&o=CURRENT_REVISION&o=CURRENT_COMMIT&o=CURRENT_FILES&o=DOWNLOAD_COMMANDS HTTP/1.0
@@ -973,6 +995,10 @@
 MergePatchSetInput and add a new patch set to the change corresponding
 to the new merge commit.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the current patch set, the same email will be used as the committer email in the
+new patch set; otherwise, the user's preferred email will be used.
+
 .Request
 ----
   POST /changes/test~master~Ic5466d107c5294414710935a8ef3b0180fb848dc/merge  HTTP/1.0
@@ -1026,6 +1052,10 @@
 
 Creates a new patch set with a new commit message.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the current patch set, the same email will be used as the committer email in the
+new patch set; otherwise, the user's preferred email will be used.
+
 The new commit message must be provided in the request body inside a
 link:#commit-message-input[CommitMessageInput] entity. If a Change-Id
 footer is specified, it must match the current Change-Id footer. If
@@ -1173,7 +1203,7 @@
 The request body does not need to include a link:#abandon-input[
 AbandonInput] entity if no review comment is added.
 
-Abandoning a change also removes all users from the link:#attention-set[attention set].
+Abandoning a change also removes all users from the link:user-attention-set.html[attention set].
 
 .Request
 ----
@@ -1301,6 +1331,10 @@
 
 Rebases a change.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the current patch set, the same email will be used as the committer email in the
+new patch set; otherwise, the user's preferred email will be used.
+
 Optionally, the parent revision can be changed to another patch set through the
 link:#rebase-input[RebaseInput] entity.
 
@@ -1427,6 +1461,10 @@
 
 Rebases an ancestry chain of changes.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the current patch set, the same email will be used as the committer email in the
+new patch set; otherwise, the user's preferred email will be used.
+
 The operated change is treated as the chain tip. All unsubmitted ancestors are rebased.
 
 Requires a linear ancestry relation (single parenting throughout the chain).
@@ -1434,6 +1472,9 @@
 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.
 
+Providing a `committer_email` through the link:#rebase-input[RebaseInput] entity is not supported
+when rebasing a chain.
+
 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.
@@ -1906,10 +1947,15 @@
 
 Submits a change.
 
+If the submission results in a new patch set (due to a rebase or cherry-pick merge method), the
+committer email will remain the same as the one used in the previous commit, provided that one of
+the secondary emails associated with the user performing the operation was used as the committer
+email in the previous commit. Otherwise, the user's preferred email will be used.
+
 The request body only needs to include a link:#submit-input[
 SubmitInput] entity if submitting on behalf of another user.
 
-Submitting a change also removes all users from the link:#attention-set[attention set].
+Submitting a change also removes all users from the link:user-attention-set.html[attention set].
 
 .Request
 ----
@@ -2263,6 +2309,10 @@
 
 Creates a new patch set on a destination change from the provided patch.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the current patch set, the same email will be used as the committer email in the
+new patch set; otherwise, the user's preferred email will be used.
+
 The patch must be provided in the request body, inside a
 link:#applypatchpatchset-input[ApplyPatchPatchSetInput] entity.
 
@@ -2655,7 +2705,7 @@
 notifying *OWNER* instead of *ALL*.
 
 Marking a change work in progress also removes all users from the
-link:#attention-set[attention set].
+link:user-attention-set.html[attention set].
 
 .Request
 ----
@@ -2686,7 +2736,7 @@
 if no review comment is added.
 
 Marking a change ready for review also adds all of the reviewers of the change
-to the link:#attention-set[attention set].
+to the link:user-attention-set.html[attention set].
 
 .Request
 ----
@@ -2839,6 +2889,81 @@
   ]
 ----
 
+[[get-custom-keyed-values]]
+=== Get Custom Keyed Values
+--
+'GET /changes/link:#change-id[\{change-id\}]/custom_keyed_values'
+--
+
+Gets the custom keyed values associated with a change.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/custom_keyed_values HTTP/1.0
+----
+
+As response the change's custom keyed values are returned as a map of strings.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "key1": "value1",
+    "key2": "value2"
+  }
+----
+
+[[set-custom-keyed-values]]
+=== Set Custom Keyed Values
+--
+'POST /changes/link:#change-id[\{change-id\}]/custom_keyed_values'
+--
+
+Adds and/or removes custom keyed values from a change.
+
+The custom keyed values to add or remove must be provided in the request body
+inside a link:#custom-keyed-values-input[CustomKeyedValuesInput] entity.
+
+Note that custom keyed values are expected to be small in both key and value.
+A typical use-case would be storing the ID to some external system, in which
+case the key would be something unique to that system and the value would be
+the ID.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/custom_keyed_values HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "add" : {
+      "key1": "value1"
+    },
+    "remove" : [
+      "key2"
+    ]
+  }
+----
+
+As response the change's custom keyed values are returned as a map of strings to strings.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "key1": "value1",
+    "key3": "value3"
+  }
+----
+
+
 [[list-change-messages]]
 === List Change Messages
 --
@@ -3100,11 +3225,15 @@
 [[put-edit-file]]
 === Change file content in Change Edit
 --
-'PUT /changes/link:#change-id[\{change-id\}]/edit/path%2fto%2ffile
+'PUT /changes/link:#change-id[\{change-id\}]/edit/path%2fto%2ffile'
 --
 
 Put content of a file to a change edit.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the latest patch set, the same email will be used as the committer email in the
+new change edit commit; otherwise, the user's preferred email will be used.
+
 .Request
 ----
   PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit/foo HTTP/1.0
@@ -3151,7 +3280,7 @@
 [[post-edit]]
 === Restore file content or rename files in Change Edit
 --
-'POST /changes/link:#change-id[\{change-id\}]/edit
+'POST /changes/link:#change-id[\{change-id\}]/edit'
 --
 
 Creates empty change edit, restores file content or renames files in change
@@ -3159,6 +3288,10 @@
 link:#change-edit-input[ChangeEditInput] entity when a file within change
 edit should be restored or old and new file names to rename a file.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the latest patch set, the same email will be used as the committer email in the
+new change edit commit; otherwise, the user's preferred email will be used.
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit HTTP/1.0
@@ -3195,13 +3328,17 @@
 [[put-change-edit-message]]
 === Change commit message in Change Edit
 --
-'PUT /changes/link:#change-id[\{change-id\}]/edit:message
+'PUT /changes/link:#change-id[\{change-id\}]/edit:message'
 --
 
 Modify commit message. The request body needs to include a
 link:#change-edit-message-input[ChangeEditMessageInput]
 entity.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the latest patch set, the same email will be used as the committer email in the
+new change edit commit; otherwise, the user's preferred email will be used.
+
 .Request
 ----
   PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit:message HTTP/1.0
@@ -3230,6 +3367,10 @@
 completely. This is not the same as reverting or restoring a file to its
 previous contents.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the latest patch set, the same email will be used as the committer email in the
+new change edit commit; otherwise, the user's preferred email will be used.
+
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit/foo HTTP/1.0
@@ -3397,11 +3538,15 @@
 [[rebase-edit]]
 === Rebase Change Edit
 --
-'POST /changes/link:#change-id[\{change-id\}]/edit:rebase
+'POST /changes/link:#change-id[\{change-id\}]/edit:rebase'
 --
 
 Rebases change edit on top of latest patch set.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the latest patch set, the same email will be used as the committer email in the
+new change edit commit; otherwise, the user's preferred email will be used.
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/edit:rebase HTTP/1.0
@@ -4324,32 +4469,8 @@
 added as a reviewer, otherwise (if they only commented) they are added to
 the CC list.
 
-Some updates to the attention set occur here. If more than one update should
-occur, only the first update in the order of the below documentation occurs:
-
-If a user is part of remove_from_attention_set, the user will be explicitly
-removed from the attention set.
-
-If a user is part of add_to_attention_set, the user will be explicitly
-added to the attention set.
-
-If the boolean ignore_default_attention_set_rules is set to true, all
-other rules below will be ignored:
-
-The user who created the review is removed from the attention set.
-
-If the change is ready for review, the following also apply:
-
-When the uploader replies, the owner is added to the attention set.
-
-When the owner or uploader replies, all the reviewers are added to
-the attention set.
-
-When neither the owner nor the uploader replies, add the owner and the
-uploader to the attention set.
-
-Then, new reviewers are added to the attention set, and removed reviewers
-(by becoming CC) are removed from the attention set.
+Posting a review usually updates the link:user-attention-set.html[attention
+set].
 
 A review cannot be set on a change edit. Trying to post a review for a
 change edit fails with `409 Conflict`.
@@ -4677,7 +4798,7 @@
 --
 
 Submits a revision.
-Submitting a change also removes all users from the link:#attention-set[attention set].
+Submitting a change also removes all users from the link:user-attention-set.html[attention set].
 
 .Request
 ----
@@ -5511,6 +5632,10 @@
 exists and the fix refers to the current patch set, or the fix refers to the
 patch set on which the change edit is based.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the current patch set, the same email will be used as the committer email in the
+new change edit commit; otherwise, the user's preferred email will be used.
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/fixes/8f605a55_f6aa4ecc/apply HTTP/1.0
@@ -5577,6 +5702,10 @@
 application of the fixes creates a new change edit. `Apply Provided Fix` can only be applied on the current
 patchset.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the current patch set, the same email will be used as the committer email in the
+new change edit commit; otherwise, the user's preferred email will be used.
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/fix:apply HTTP/1.0
@@ -6232,6 +6361,10 @@
 
 Cherry picks a revision to a destination branch.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the original revision, the same email will be used as the committer email
+in the new patch set; otherwise, the user's preferred email will be used.
+
 To cherry pick a commit with no change-id associated with it, see
 link:rest-api-projects.html#cherry-pick-commit[CherryPickCommit].
 
@@ -6548,40 +6681,6 @@
   HTTP/1.1 204 No Content
 ----
 
-[[attention-set]]
-== Attention Set
-Attention Set is the set of users that should perform some action on the
-change. E.g, reviewers should review the change, owner/uploader should
-add a new patchset or respond to comments.
-
-Users are added to the attention set if one the following apply:
-
-* They are manually added in link:#review-input[ReviewInput] in
- add_to_attention_set.
-* They are added as reviewers.
-* The change is marked ready for review.
-* As an owner/uploader, when someone replies on your change.
-* As a reviewer, when the owner/uploader replies.
-* When the user's vote is deleted by another user.
-* The rules above (except manually adding to the attention set) don't apply
- for changes that are work in progress.
-
-Users are removed from the attention set if one the following apply:
-
-* They are manually removed in link:#review-input[ReviewInput] in
- remove_from_attention_set.
-* They are removed from reviewers.
-* The change is marked work in progress, abandoned, or submitted.
-* When the user replies on a change.
-
-If the ignore_default_attention_set_rules in link:#review-input[ReviewInput]
-is set to true, no other changes to the attention set will occur during the
-link:#set-review[set-review].
-Also, users specified in the list will occur instead of any of the implicit
-changes to the attention set. E.g, if a user is added by add_to_attention_set
-in link:#review-input[ReviewInput], but also the change is marked work in
-progress, the user will still be added.
-
 [[ids]]
 == IDs
 
@@ -6593,22 +6692,22 @@
 [[change-id]]
 === \{change-id\}
 Identifier that uniquely identifies one change. It contains the URL-encoded
-project name as well as the change number: "'$$<project>~<changeNumber>$$'"
+project name as well as the change number: "<project>~<changeNumber>"
 
 ==== Alternative identifiers
 Gerrit also supports an array of other change identifiers.
 
 [NOTE]
 Even though these identifiers will work in the majority of cases it is highly
-recommended to use "'$$<project>~<changeNumber>$$'" whenever possible.
+recommended to use "<project>~<changeNumber>" whenever possible.
 Since these identifiers require additional lookups from index and caches, to
-be translated to the "'$$<project>~<changeNumber>$$'" identifier, they
+be translated to the "<project>~<changeNumber>" identifier, they
 may result in both false-positives and false-negatives.
 Furthermore the additional lookup mean that they come with a performance penalty.
 
-* an ID of the change in the format "'$$<project>~<branch>~<Change-Id>$$'",
+* an ID of the change in the format "<project>\~<branch>~<Change-Id>",
   where for the branch the `refs/heads/` prefix can be omitted
-  ("$$myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940$$")
+  ("myProject\~master~I8473b95934b5732ac55d26311a706c9c2bde9940")
 * a Change-Id if it uniquely identifies one change
   ("I8473b95934b5732ac55d26311a706c9c2bde9940")
 * a change number if it uniquely identifies one change ("4247")
@@ -6800,6 +6899,10 @@
 caller.
 |`response_format_options`     |optional|
 List of link:#query-options[query options] to format the response.
+|`amend`              |optional|
+If true, the revision from the URL will be amended by the patch. This will use the tree of the
+revision, apply the patch and create a new commit whose tree is the resulting tree of the operation
+and whose parent(s) are the parent(s) of the revision. Cannot be used together with `base`.
 |=================================
 
 
@@ -6839,7 +6942,7 @@
 [[attention-set-info]]
 === AttentionSetInfo
 The `AttentionSetInfo` entity contains details of users that are in
-the link:#attention-set[attention set].
+the link:user-attention-set.html[attention set].
 
 [options="header",cols="1,^1,5"]
 |===========================
@@ -6858,7 +6961,7 @@
 [[attention-set-input]]
 === AttentionSetInput
 The `AttentionSetInput` entity contains details for adding users to the
-link:#attention-set[attention set] and removing them from it.
+link:user-attention-set.html[attention set] and removing them from it.
 
 [options="header",cols="1,^1,5"]
 |===========================
@@ -6926,6 +7029,9 @@
 |==================================
 |Field Name           ||Description
 |`id`                 ||
+The ID of the change. The format is "'<project>\~<_number>'".
+'project' and '_number' are URL encoded. The callers must not rely on the format.
+|`triplet_id`         ||
 The ID of the change in the format "'<project>\~<branch>~<Change-Id>'",
 where 'project' and 'branch' are URL encoded. For 'branch' the
 `refs/heads/` prefix is omitted.
@@ -6946,6 +7052,10 @@
  of the account from the attention set.
 |`hashtags`           |optional|
 List of hashtags that are set on the change.
+|`custom_keyed_values`       |optional|
+A map that maps custom keys to custom values that are tied to a specific change,
+both in the form of strings. Only set if link:#custom-keyed-values[custom keyed
+values] are requested.
 |`change_id`          ||The Change-Id of the change.
 |`subject`            ||
 The subject of the change (header line of the commit message).
@@ -6964,11 +7074,8 @@
 The user who submitted the change, as an
 link:rest-api-accounts.html#account-info[ AccountInfo] entity.
 |`starred`            |not set if `false`|
-Whether the calling user has starred this change with the default label.
+Whether the calling user has starred this change.
 Only set if link:#star[requested].
-|`stars`              |optional|
-A list of star labels that are applied by the calling user to this
-change. The labels are lexicographically sorted.
 |`reviewed`           |not set if `false`|
 Whether the change was reviewed by the calling user.
 Only set if link:#reviewed[reviewed] is requested.
@@ -7174,6 +7281,8 @@
 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.
+|`custom_keyed_values`|optional|Custom keyed values as a
+map from custom keys to values.
 |`merge`              |optional|
 The detail of a merge commit as a link:#merge-input[MergeInput] entity.
 If set, the target branch (see  `branch` field) must exist (it is not
@@ -7223,9 +7332,10 @@
 |`message`            ||
 The text left by the user or Gerrit system. Accounts are served as account IDs
 inlined in the text as `<GERRIT_ACCOUNT_18419>`.
-All accounts, used in message, can be found in `accountsInMessage`
+All accounts, used in message, can be found in `accounts_in_message`
 field.
-|`accountsInMessage`            ||Accounts, used in `message`.
+|`accounts_in_message` ||
+link:rest-api-accounts.html#account-info[AccountInfo] list, used in `message`.
 |`tag`                 |optional|
 Value of the `tag` field from link:#review-input[ReviewInput] set
 while posting the review. Votes/comments that contain `tag` with
@@ -7283,6 +7393,12 @@
 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.
+|`committer_email`|optional|
+Cherry-pick is committed using this email address. Only the registered emails
+of the calling user are considered valid. Defaults to source commit's committer
+email if it is a registered email of the calling user, else defaults to calling
+user's preferred email.
+
 |`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
@@ -7558,7 +7674,7 @@
 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.
+link:user-attention-set.html[attention set]. When not set, the default is false.
 |`reason`         |optional|
 The reason why this vote is deleted. Will +
 go into the change message.
@@ -7694,6 +7810,19 @@
 === ApplyProvidedFixInput
 The `ApplyProvidedFixInput` entity contains the fixes to be applied on a review.
 
+[[custom-keyed-values-input]]
+=== CustomKeyedValuesInput
+
+The `CustomKeyedValuesInput` entity contains information about custom keyed values
+to add to, and/or remove from, a change.
+
+[options="header",cols="1,^1,5"]
+|=======================
+|Field Name||Description
+|`add`     |optional|The map of custom keyed values to be added to the change.
+|`remove`  |optional|The list of custom keys to be removed from the change.
+|=======================
+
 [options="header",cols="1,6"]
 |=======================
 |Field Name              |Description
@@ -8031,6 +8160,14 @@
 The caller needs "Forge Author" permission when using this field.
 This field does not affect the owner or the committer of the change, which will
 continue to use the identity of the caller.
+|`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.
 |==================================
 
 [[move-input]]
@@ -8067,6 +8204,34 @@
 identify the accounts that should be should be notified.
 |=======================
 
+[[parent-info]]
+=== ParentInfo
+The `ParentInfo` entity contains information about the parent commit of a
+patch-set.
+
+[options="header",cols="1,^1,5"]
+|=======================
+|Field Name||Description
+|`branch_name` |optional|Name of the target branch into which the parent commit
+is merged.
+|`commit_id` |optional|The commit SHA-1 of the parent commit, or null if the
+current commit is root.
+|`is_merged_in_target_branch` |optional, default to false| Set to true if the
+parent commit is merged into the target branch.
+|`change_id` |optional| If the parent commit is a patch-set of another gerrit
+change, this field will hold the change ID of the parent change. Otherwise,
+will be null.
+|`change_number` |optional|If the parent commit is a patch-set of another gerrit
+change, this field will hold the change number of the parent change. Otherwise,
+will be null.
+|`patch_set_number` |optional|If the parent commit is a patch-set of another gerrit
+change, this field will hold the patch-set number of the parent change. Otherwise,
+will be null.
+|`change_status` |optional|If the parent commit is a patch-set of another gerrit
+change, this field will hold the change status of the parent change. Otherwise,
+will be null.
+|=======================
+
 [[private-input]]
 === PrivateInput
 The `PrivateInput` entity contains information for changing the private
@@ -8167,6 +8332,9 @@
 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.
+|`strategy`       |optional|
+The strategy of the merge, can be `recursive`, `resolve`,
+`simple-two-way-in-core`, `ours` or `theirs`, default will use project settings.
 |`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
@@ -8184,6 +8352,10 @@
 In addition, rebasing on behalf of the uploader is only supported for the
 current patch set of a change. +
 If the caller is the uploader this flag is ignored and a normal rebase is done.
+|`committer_email`|optional|
+Rebase is committed using this email address. Only the registered emails
+of the calling user or uploader (when `on_behalf_of_uploader` is `true`) are
+considered valid. This option is not supported when rebasing a chain.
 |`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
@@ -8430,15 +8602,17 @@
 `ready` and `work_in_progress` to be true.
 |`add_to_attention_set`                |optional|
 list of link:#attention-set-input[AttentionSetInput] entities to add
-to the link:#attention-set[attention set]. Users that are not reviewers,
+to the link:user-attention-set.html[attention set]. Users that are not reviewers,
 ccs, owner, or uploader are silently ignored.
 |`remove_from_attention_set`           |optional|
 list of link:#attention-set-input[AttentionSetInput] entities to remove
-from the link:#attention-set[attention set].
+from the link:user-attention-set.html[attention set].
 |`ignore_automatic_attention_set_rules`|optional|
 If set to true, ignore all automatic attention set rules described in the
-link:#attention-set[attention set]. Updates in add_to_attention_set
+link:user-attention-set.html[attention set]. Updates in add_to_attention_set
 and remove_from_attention_set are not ignored.
+|`response_format_options`     |optional|
+List of link:#query-options[query options] to format the response.
 |============================
 
 [[review-result]]
@@ -8462,6 +8636,9 @@
 action. Not set if false.
 |`error`                  |optional|
 Error message for non-200 responses.
+|`change_info`            |optional|
+Post-update change information. Only set if `response_format_options` were
+set.
 |============================
 
 [[reviewer-info]]
@@ -8587,6 +8764,19 @@
 interface is installed.
 |`commit`      |optional|The commit of the patch set as
 link:#commit-info[CommitInfo] entity.
+|`parents_data`     |optional|
+The parent commits of this patch-set commit as a list of
+link:#parent-info[ParentInfo] entities. In each parent, we include the target
+branch name if the parent is a merged commit in the target branch. Otherwise,
+we include the change and patch-set numbers of the parent change. +
+Only set if the `PARENTS` option is set.
+|`branch`      | optional|The name of the target branch that this revision is
+set to be merged into. +
+Note that if the change is moved with the link:#move-change[Move Change]
+endpoint, this field can be different for different patchsets. For example,
+if the change is moved and a new patchset is uploaded afterwards, the
+link:#revision-info[RevisionInfo] of the previous patchset will contain the old
+`branch`, but the newer patchset will contain the new `branch`.
 |`files`       |optional|
 The files of the patch set as a map that maps the file names to
 link:#file-info[FileInfo] entities. Only set if
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index fe9b13c..ec1ac03 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -30,6 +30,32 @@
   "2.7"
 ----
 
+The `verbose` option can be used to provide a verbose version output as
+link:#version-info[VersionInfo].
+
+.Request
+----
+  GET /config/server/version?verbose HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "gerrit_version": "3.8.0",
+    "note_db_version": 185,
+    "change_index_version": 83,
+    "account_index_version": 13,
+    "project_index_version": 6,
+    "group_index_version": 10
+  }
+----
+
+
+
 [[get-info]]
 === Get Server Info
 --
@@ -695,11 +721,6 @@
 +
 Includes a JVM summary.
 
-* `gc`:
-+
-Requests a Java garbage collection before computing the information
-about the Java memory heap.
-
 .Request
 ----
   GET /config/server/summary?jvm HTTP/1.0
@@ -1845,6 +1866,8 @@
 link:config-gerrit.html#gerrit.reportBugUrl[URL to report bugs].
 |`instance_id`       |optional|
 link:config-gerrit.html#gerrit.instanceId[Short identifier for this Gerrit installation].
+|`default_branch`       |optional|
+link:config-gerrit.html#gerrit.defaultBranch[Name of the default branch to use on the project creation].
 |=================================
 
 [[index-config-info]]
@@ -1968,6 +1991,22 @@
 details.
 |=======================================
 
+[[version-info]]
+=== VersionInfo
+The `VersionInfo` entity contains information about the version of the
+Gerrit server.
+
+[options="header",cols="1,^1,5"]
+|=======================================
+|Field Name                ||Description
+|`gerrit_version`          ||Gerrit server version
+|`note_db_version`         ||NoteDb version
+|`change_index_version`    ||Change index version
+|`account_index_version`   ||Account index version
+|`project_index_version`   ||Project index version
+|`group_index_version`     ||Group index version
+|=======================================
+
 [[server-info]]
 === ServerInfo
 The `ServerInfo` entity contains information about the configuration of
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 675c054..fff9d0b 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -139,11 +139,16 @@
   )]}'
   {
     "some-project": {
-      "id": "some-project"
+      "id": "some-project",
+      _more_projects: true
     }
   }
 ----
 
+If the number of projects matching the query exceeds either the
+internal limit or a supplied `limit` query parameter, the last project
+object has a `_more_projects: true` JSON field set.
+
 
 [[suggest-projects]]
 Prefix(p)::
@@ -433,6 +438,10 @@
   GET /projects/?query=<query>&limit=25 HTTP/1.0
 ----
 
+If the number of projects matching the query exceeds either the
+internal limit or a supplied `limit` query parameter, the last project
+object has a `_more_projects: true` JSON field set.
+
 The `/projects/` URL also accepts a start integer in the `start`
 parameter. The results will skip `start` projects from project list.
 
@@ -1609,6 +1618,11 @@
 As result a list of link:#branch-info[BranchInfo] entries is
 returned.
 
+If the `limit` parameter was set and the number of branches is larger than the
+`limit`, the response header `X-GERRIT-NEXT-PAGE-TOKEN` will be set. Clients
+can pass this token with subsequent requests (using the `next-page-token`
+request parameter) for pagination to skip over previous results.
+
 .Request
 ----
   GET /projects/work%2Fmy-project/branches/ HTTP/1.0
@@ -1750,6 +1764,11 @@
   ]
 ----
 
+Next-page-token(t)::
+Skips over previous results. Cannot be set simultaneously with the `Skip`
+parameter, and also must be set to an exact token received by the server in a
+previous call, otherwise the request would fail with `400 Bad Request`.
+
 [[get-branch]]
 === Get Branch
 --
@@ -1895,6 +1914,69 @@
   Ly8gQ29weXJpZ2h0IChDKSAyMDEwIFRoZSBBbmRyb2lkIE9wZW4gU291cmNlIFByb2plY...
 ----
 
+[[suggest-reviewers]]
+=== Suggest Reviewers
+--
+'GET /projects/link:#project-name[\{project-name\}]/branches/link:#branch-id[\{branch-id\}]/suggest_reviewers?q=J&n=5'
+--
+
+Suggest the reviewers for a given query `q` and result limit `n`. If result
+limit is not passed, then the default 10 is used.
+
+This REST endpoint only suggests accounts that
+
+* are active
+* can see the branch
+* are visible to the calling user
+* are not service users (unless
+  link:config.html#suggest.skipServiceUsers[skipServiceUsers] is set to `false`)
+
+Groups can be excluded from the results by specifying the 'exclude-groups'
+request parameter:
+
+--
+'GET /changes/link:#project-name[\{project-name\}]/branches/link:#branch-id[\{branch-id\}]/suggest_reviewers?q=J&n=5&exclude-groups'
+--
+
+As result a list of link:#suggested-reviewer-info[SuggestedReviewerInfo] entries is returned.
+
+.Request
+----
+  GET /projects/myProject/branches/myBranch/suggest_reviewers?q=J HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    {
+      "account": {
+        "_account_id": 1000097,
+        "name": "Jane Roe",
+        "email": "jane.roe@example.com"
+      },
+      "count": 1
+    },
+    {
+      "group": {
+        "id": "4fd581c0657268f2bdcc26699fbf9ddb76e3a279",
+        "name": "Joiner"
+      },
+      "count": 5
+    }
+  ]
+----
+
+To suggest CCs `reviewer-state=CC` can be specified as additional URL
+parameter.
+--
+'GET /changes/link:#project-name[\{project-name\}]/branches/link:#branch-id[\{branch-id\}]/suggest_reviewers?q=J&reviewer-state=CC'
+--
+
 
 [[get-mergeable-info]]
 === Get Mergeable Information
@@ -2671,6 +2753,10 @@
 
 Cherry-picks a commit of a project to a destination branch.
 
+If one of the secondary emails associated with the user performing the operation was used as the
+committer email in the original commit, the same email will be used as the committer email in the
+new patch set; otherwise, the user's preferred email will be used.
+
 To cherry pick a change revision, see link:rest-api-changes.html#cherry-pick[CherryPick].
 
 The destination branch must be provided in the request body inside a
@@ -4383,28 +4469,31 @@
 The `ProjectInfo` entity contains information about a project.
 
 [options="header",cols="1,^2,4"]
-|===========================
-|Field Name    ||Description
-|`id`          ||The URL encoded project name.
-|`name`        |
+|=============================
+|Field Name      ||Description
+|`id`            ||The URL encoded project name.
+|`name`          |
 not set if returned in a map where the project name is used as map key|
 The name of the project.
-|`parent`      |optional|
+|`parent`        |optional|
 The name of the parent project. +
 `?-<n>` if the parent project is not visible (`<n>` is a number which
 is increased for each non-visible project).
-|`description` |optional|The description of the project.
-|`state`       |optional|`ACTIVE`, `READ_ONLY` or `HIDDEN`.
-|`branches`    |optional|Map of branch names to HEAD revisions.
-|`labels`      |optional|
+|`description`   |optional|The description of the project.
+|`state`         |optional|`ACTIVE`, `READ_ONLY` or `HIDDEN`.
+|`branches`      |optional|Map of branch names to HEAD revisions.
+|`labels`        |optional|
 Map of label names to
 link:#label-type-info[LabelTypeInfo] entries.
 This field is filled for link:#create-project[Create Project] and
 link:#get-project[Get Project] calls.
-|`web_links`   |optional|
+|`web_links`     |optional|
 Links to the project in external sites as a list of
 link:rest-api-changes.html#web-link-info[WebLinkInfo] entries.
-|===========================
+|`_more_projects`|optional, not set if `false`|
+Whether the query would deliver more results if not limited. +
+Only set on the last project that is returned.
+|=============================
 
 [[project-input]]
 === ProjectInput
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
index 4fe5aae..9825478 100644
--- a/Documentation/user-attention-set.txt
+++ b/Documentation/user-attention-set.txt
@@ -97,21 +97,21 @@
 
 === Dashboard
 
-The default *dashboard* contains a new section at the top called "Your Turn". It
+The default *dashboard* contains a new section at the top called "Your turn". It
 lists all changes where the logged-in user is in the attention set. When you are
 a reviewer, the change is highlighted and is shown at the top of the section.
 The "Waiting" column indicates how long the owner has already been waiting for
 you to act.
 
-image::images/user-attention-set-dashboard.png["dashboard with Your Turn section", align="center"]
+image::images/user-attention-set-dashboard.png["dashboard with Your turn section", align="center"]
 
 As an active developer, one of your daily goals will be to iterate over this
 list and clear it.
 
-image::images/user-attention-set-dashboard-empty.png["dashboard with empty Your Turn section", align="center"]
+image::images/user-attention-set-dashboard-empty.png["dashboard with empty Your turn section", align="center"]
 
 Note that you can also navigate to other users' dashboards to check their
-"Your Turn" section.
+"Your turn" section.
 
 === Emails
 
@@ -188,3 +188,18 @@
 
 SEARCHBOX
 ---------
+
+=== Auto readd owner [[auto-readd-owner]]
+
+This job automatically readds the change owner to the attention-set for open non-WIP/private
+changes that have been inactive for a defined time. Gerrit administrators may configure
+link:config-gerrit.html#auto-readd[this]
+
+Readding the owner to the attention-set of an inactive change has the advantages:
+
+* It signals the change owner that the review is not progressing and that the owner
+may need to adjust the attention-set or indicate a need for a priority review.
+* It may prevent changes where no one is in the attention-set from getting forgotten.
+* It makes people set changes in WIP or private for changes that should not
+be actively reviewed.
+
diff --git a/Documentation/user-named-destinations.txt b/Documentation/user-named-destinations.txt
index a1ab258..cee562b 100644
--- a/Documentation/user-named-destinations.txt
+++ b/Documentation/user-named-destinations.txt
@@ -1,15 +1,19 @@
 = Gerrit Code Review - Named Destinations
 
-[[user-named-destinations]]
-== User Named Destinations
-It is possible to define named destination sets on a user level.
+[[user-or-group-named-destinations]]
+== User Or Group Named Destinations
+It is possible to define named destination sets on a user or group level.
 To do this, define the named destination sets in files named after
 each destination set in the `destinations` directory of the user's
-account ref in the `All-Users` project.  The user's account ref is
-based on the user's account id which is an integer.  The account
-refs are sharded by the last two digits (`+nn+`) in the refname,
-leading to refs of the format `+refs/users/nn/accountid+`.  The
-user's destination files are a 2 column tab delimited file.  Each
+or group's account ref in the `All-Users` project. The user's account ref is
+based on the user's account id which is an integer. The user account refs
+are sharded by the last two digits (`+nn+`) in the refname, leading to refs
+of the format `+refs/users/nn/accountid+`. Similarly, the group's ref is
+based on the group id which is a UUID. The group refs are sharded
+by the first 2 characters of the group UUID, leading to a refs of the
+format `+refs/groups/cc/groupid+`.
+
+The destination files are a 2 column tab delimited file.  Each
 row in a destination file represents a single destination in the
 named set.  The left column represents the ref of the destination,
 and the right column represents the project of the destination.
diff --git a/Documentation/user-named-queries.txt b/Documentation/user-named-queries.txt
index c01f790..938cd53 100644
--- a/Documentation/user-named-queries.txt
+++ b/Documentation/user-named-queries.txt
@@ -1,11 +1,12 @@
 = Gerrit Code Review - Named Queries
 
-[[user-named-queries]]
-== User Named Queries
-It is possible to define named queries on a user level. To do
+[[user-or-group-named-queries]]
+== User Or Group Named Queries
+It is possible to define named queries on a user or group level. To do
 this, define the named queries in the `queries` file under the
-link:intro-user.html#user-refs[user's ref] in the `All-Users` project.  The
-user's queries file is a 2 column tab delimited file.  The left
+link:intro-user.html#user-refs[user's ref] or
+link:config-groups.html#_storage_format[group's ref] in the `All-Users`
+project. The named queries file is a 2 column tab delimited file. The left
 column represents the name of the query, and the right column
 represents the query expression represented by the name. The named queries
 can be publicly accessible by other users.
diff --git a/Documentation/user-review-ui.txt b/Documentation/user-review-ui.txt
index 39929e1..899c7a7 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -280,9 +280,14 @@
 
 ** [[cherry-pick]]`Cherry-Pick`:
 +
-Allows to cherry-pick the change to another branch. The destination
-branch can be selected from a dialog. Cherry-picking a change creates a
-new open change on the selected destination branch.
+Allows to cherry-pick the change to another branch. The destination branch
+can be selected from a dialog. Cherry-picking a change creates a new open
+change on the selected destination branch. 'Cherry-pick committer email'
+drop-down is visible for single change cherry-picks when user has more than
+one email registered to their account. It is possible to select any of the
+registered emails to be used as the cherry-pick committer email. It defaults
+to source commit's committer email if it is a registered email of the calling
+user, else defaults to calling user's preferred email.
 +
 It is also possible to cherry-pick a change to the same branch. This is
 effectively the same as rebasing it to the current tip of the
@@ -655,6 +660,44 @@
 
 image::images/user-review-ui-side-by-side-diff-screen-rename.png[width=400, link="images/user-review-ui-side-by-side-diff-screen-rename.png"]
 
+[[normal-and-rebase-edits]]
+=== Normal and Rebase Edits
+
+In the diff view, Gerrit shows added and removed contents with green and red
+colors respectively.
+
+image::images/user-review-ui-side-by-side-diff-normal-edits.png[width=800, link="images/user-review-ui-side-by-side-diff-normal-edits.png"]
+
+When comparing two patch-sets against each other, and if both patch-sets have
+different bases (parents), Gerrit also identifies parts of the diff that were
+modified due to rebase. Those are called “rebase edits” and are highlighted with
+different colors.
+
+image::images/user-review-ui-side-by-side-diff-rebase-edits.png[width=800, link="images/user-review-ui-side-by-side-diff-rebase-edits.png"]
+
+Gerrit identifies rebase edits by also inspecting the diff between parents, and
+if it detects an edit between parents that’s also an edit between the patch-sets
+(after mapping/transforming the edit), then it marks it as a rebase edit. This
+first diffs both patch-sets to identify all edits, then potentially excludes
+some of them if they were identified as rebase edits.
+
+image::images/user-review-ui-side-by-side-diff-rebase.png[width=400, link="images/user-review-ui-side-by-side-diff-rebase.png"]
+
+If all edits in a file were due to rebase, the file is skipped and is not shown
+among the list of files in the ‘files tab’.
+
+[[hazardous-rebases]]
+=== Hazardous Rebases
+
+A rebase might be hazardous in some cases. One such example is when users have a
+stack of changes (e.g. two changes as in the below figure) then squash both
+changes and upload the resulting commit as patch-set 2. In this case, PS1 and
+PS2 are identical and Gerrit shows an empty diff, which is a correct diff
+but it's hiding the fact that new content got implicitly merged into this change
+from the parent change.
+
+image::images/user-review-ui-side-by-side-diff-squash.png[width=400, link="images/user-review-ui-side-by-side-diff-squash.png"]
+
 [[inline-comments]]
 === Inline Comments
 
diff --git a/Documentation/user-search-projects.txt b/Documentation/user-search-projects.txt
index 8ebbf3e..aabbd55 100644
--- a/Documentation/user-search-projects.txt
+++ b/Documentation/user-search-projects.txt
@@ -12,6 +12,17 @@
 +
 Matches projects that have exactly the name 'NAME'.
 
+[[prefix]]
+prefix:'PREFIX'::
++
+Matches projects that have a name that starts with 'PREFIX' (may be
+case-sensitive, depending on which index backend is used).
+
+[[substring]]
+substring:'SUBSTRING'::
++
+Matches projects that have a name that contains 'SUBSTRING' (case-insensitive).
+
 [[parent]]
 parent:'PARENT'::
 +
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 67b8d75..0744ded 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -128,11 +128,13 @@
 as a change number such as 15183, or a Change-Id from the Change-Id footer.
 
 [[destination]]
-destination:'[name=]NAME[,user=USER]'::
+destination:'[name=]NAME[,user=USER|,group=GROUP]'::
 +
-Changes which match the specified USER's destination named 'NAME'. If 'USER'
-is unspecified, the current user is used. The named destinations can be
-publicly accessible by other users.
+Changes which match the specified USER's or GROUP's destination named 'NAME'.
+If 'USER' is unspecified, the current user is used. The named destinations can
+be publicly accessible by other users.
+The value may be wrapped in double quotes to include spaces. For example,
+`destination:"myreviews,group=My Group"`
 (see link:user-named-destinations.html[Named Destinations]).
 
 [[owner]]
@@ -160,11 +162,13 @@
 'GROUP'.
 
 [[query]]
-query:'[name=]NAME[,user=USER]'::
+query:'[name=]NAME[,user=USER|,group=GROUP]'::
 +
-Changes which match the specified USER's query named 'NAME'. If 'USER'
-is unspecified, the current user is used. The named queries can be
-publicly accessible by other users.
+Changes which match the specified USER's or GROUP's query named 'NAME'.
+If neither 'USER' nor 'GROUP' is specified, the current user is used.
+The named queries can be publicly accessible by other users.
+The value may be wrapped in double quotes to include spaces. For example,
+`query:"myquery,group=My Group"`
 (see link:user-named-queries.html[Named Queries]).
 
 [[reviewer]]
@@ -341,7 +345,7 @@
 of the argument.
 
 [[message]]
-message:'MESSAGE'::
+message:'MESSAGE'::, m:'MESSAGE'::, description:'MESSAGE'::, d:'MESSAGE'::
 +
 Changes that match 'MESSAGE' arbitrary string in the commit message body.
 By default full text matching is used, but regular expressions can be
diff --git a/Documentation/user-suggest-edits.txt b/Documentation/user-suggest-edits.txt
index 99f17a4..fa49eeb 100644
--- a/Documentation/user-suggest-edits.txt
+++ b/Documentation/user-suggest-edits.txt
@@ -1,7 +1,7 @@
-= Gerrit Code Review - User suggested edits (Experiment)
+= Gerrit Code Review - User suggested edits
 
 Easy and fast way for reviewers to suggest code changes that can be easily applied
-by change owner.
+by the change owner.
 
 == Reviewer workflow
 
@@ -26,8 +26,16 @@
 
 == Author workflow
 
-You can apply one or more suggested fixes. When suggested fix is applied - it creates
-a change edit that you can modify. link:user-inline-edit.html#editing-change[More about editing mode.]
+You can apply one or more suggested edits. When a suggested edit is applied it
+creates a change edit that you can further modify in Gerrit. You can read more
+about all the features of link:user-inline-edit.html#editing-change[change edit mode].
+
+FYI: Publishing a new patchset in Gerrit will make your Gerrit change out of
+sync with your local git commit. You can checkout the latest Gerrit patchset
+by using the commands from the link:user-review-ui.html#download[download drop-down panel].
+
+Alternatively, you can use the copy to clipboard button to copy a suggested
+edit to your clipboard and then you can paste it into your editor.
 
 GERRIT
 ------
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index c6fce2a5..625c2e9 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -479,6 +479,21 @@
 configured on the host, but not the link:config.html#receive.timeout[receive
 timeout].
 
+[[push_justification]]
+==== Provide a push justification
+
+When making a direct push (which directly modifies target branch, without creating a change), you
+can provide a justification for the push. To do this set `push-justification=justification` push
+option on the git push; the justification is an arbitrary text.
+
+----
+  git push -o push-justification=id/2345 ssh://john.doe@git.example.com:29418/kernel/common refs/heads/master
+----
+
+**NOTE** This options is used internally in google. The value is ignored in the upstream version
+of Gerrit.
+
+
 [[push_replace]]
 === Replace Changes
 
diff --git a/WORKSPACE b/WORKSPACE
index 047da6a..8ce21d5 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -33,10 +33,10 @@
 
 http_archive(
     name = "platforms",
-    sha256 = "379113459b0feaf6bfbb584a91874c065078aa673222846ac765f86661c27407",
+    sha256 = "3a561c99e7bdbe9173aa653fd579fe849f1d8d67395780ab4770b1f381431d51",
     urls = [
-        "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",
+        "https://mirror.bazel.build/github.com/bazelbuild/platforms/releases/download/0.0.7/platforms-0.0.7.tar.gz",
+        "https://github.com/bazelbuild/platforms/releases/download/0.0.7/platforms-0.0.7.tar.gz",
     ],
 )
 
@@ -65,8 +65,8 @@
 
 http_archive(
     name = "build_bazel_rules_nodejs",
-    sha256 = "c29944ba9b0b430aadcaf3bf2570fece6fc5ebfb76df145c6cdad40d65c20811",
-    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.7.0/rules_nodejs-5.7.0.tar.gz"],
+    sha256 = "94070eff79305be05b7699207fbac5d2608054dd53e6109f7d00d923919ff45a",
+    urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.8.2/rules_nodejs-5.8.2.tar.gz"],
 )
 
 load("@build_bazel_rules_nodejs//:repositories.bzl", "build_bazel_rules_nodejs_dependencies")
@@ -103,6 +103,12 @@
 
 register_toolchains("//tools:error_prone_warnings_toolchain_java17_definition")
 
+# Java-Prettify external repository consumed from git submodule
+local_repository(
+    name = "java-prettify",
+    path = "modules/java-prettify",
+)
+
 # JGit external repository consumed from git submodule
 local_repository(
     name = "jgit",
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 22590f7..8e282ad 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -286,7 +286,9 @@
   @Inject protected TestTicker testTicker;
 
   protected EventRecorder eventRecorder;
+
   protected GerritServer server;
+
   protected Project.NameKey project;
   protected RestSession adminRestSession;
   protected RestSession userRestSession;
@@ -318,6 +320,101 @@
   private String systemTimeZone;
   private SystemReader oldSystemReader;
 
+  /**
+   * The Getters and Setters below are needed for tests that run on custom {@link GerritServer}
+   * (that can be set up via {@link #initServer} and {@link #setUpDatabase} methods. Because tests
+   * inherit directly from {@link AbstractDaemonTest}, the set up has to be delegated to some other
+   * class that can share the set up logic across different test classes.
+   *
+   * <p>E.g, we need to be able to do something like:
+   *
+   * <pre>{@code
+   * public class AccountIT extends AbstractDaemonTest {...}
+   *
+   * public class AbstractDaemonTestAdapter {
+   *
+   *   protected void initServer() {...}
+   *
+   *   ...
+   *
+   * }
+   *
+   * public class CustomAccountIT extends AccountIT {
+   *
+   *   AbstractDaemonTestAdapter testAdapter;
+   *
+   *   {@literal @Override}
+   *   protected void initServer() {
+   *         testAdapter.initServer();
+   *   }
+   *   ...
+   * }
+   *
+   * public class CustomChangeIT extends ChangeIT {
+   *
+   *   AbstractDaemonTestAdapter testAdapter;
+   *
+   *   {@literal @Override}
+   *   protected void initServer() {
+   *         testAdapter.initServer();
+   *   }
+   *   ...
+   * }
+   *
+   * }</pre>
+   */
+  public String getResourcePrefix() {
+    return resourcePrefix;
+  }
+
+  public void setResourcePrefix(String resourcePrefix) {
+    this.resourcePrefix = resourcePrefix;
+  }
+
+  public Description getDescription() {
+    return description;
+  }
+
+  public TestRepository<InMemoryRepository> getTestRepo() {
+    return testRepo;
+  }
+
+  public void setTestRepo(TestRepository<InMemoryRepository> testRepo) {
+    this.testRepo = testRepo;
+  }
+
+  public TestAccount getUser() {
+    return user;
+  }
+
+  public void setUser(TestAccount user) {
+    this.user = user;
+  }
+
+  public TestAccount getAdmin() {
+    return admin;
+  }
+
+  public void setAdmin(TestAccount admin) {
+    this.admin = admin;
+  }
+
+  public Project.NameKey getProject() {
+    return project;
+  }
+
+  public void setProject(Project.NameKey project) {
+    this.project = project;
+  }
+
+  public GerritServer getServer() {
+    return server;
+  }
+
+  public void setServer(GerritServer server) {
+    this.server = server;
+  }
+
   @Before
   public void clearSender() {
     if (sender != null) {
@@ -409,7 +506,7 @@
     initSsh();
   }
 
-  protected void reindexAccount(Account.Id accountId) {
+  public void reindexAccount(Account.Id accountId) {
     accountIndexer.index(accountId);
   }
 
@@ -457,6 +554,52 @@
 
     baseConfig.setInt("index", null, "batchThreads", -1);
 
+    initServer(classDesc, methodDesc);
+
+    server.getTestInjector().injectMembers(this);
+    Transport.register(inProcessProtocol);
+    toClose = Collections.synchronizedList(new ArrayList<>());
+
+    setUpDatabase(classDesc);
+
+    // Set the clock step last, so that the test setup isn't consuming any timestamps after the
+    // clock has been set.
+    setTimeSettings(classDesc.useSystemTime(), classDesc.useClockStep(), classDesc.useTimezone());
+    setTimeSettings(
+        methodDesc.useSystemTime(), methodDesc.useClockStep(), methodDesc.useTimezone());
+  }
+
+  protected void setUpDatabase(GerritServer.Description classDesc) throws Exception {
+    admin = accountCreator.admin();
+    user = accountCreator.user1();
+
+    // Evict and reindex accounts in case tests modify them.
+    reindexAccount(admin.id());
+    reindexAccount(user.id());
+
+    adminRestSession = new RestSession(server, admin);
+    userRestSession = new RestSession(server, user);
+    anonymousRestSession = new RestSession(server, null);
+
+    initSsh();
+
+    String testMethodName = description.getMethodName();
+    resourcePrefix =
+        UNSAFE_PROJECT_NAME
+            .matcher(description.getClassName() + "_" + testMethodName + "_")
+            .replaceAll("");
+
+    setRequestScope(admin);
+    ProjectInput in = projectInput(description);
+    gApi.projects().create(in);
+    project = Project.nameKey(in.name);
+    if (!classDesc.skipProjectClone()) {
+      testRepo = cloneProject(project, getCloneAsAccount(description));
+    }
+  }
+
+  protected void initServer(GerritServer.Description classDesc, GerritServer.Description methodDesc)
+      throws Exception {
     Module module = createModule();
     Module auditModule = createAuditModule();
     Module sshModule = createSshModule();
@@ -472,43 +615,6 @@
           GerritServer.initAndStart(
               temporaryFolder, methodDesc, baseConfig, module, auditModule, sshModule);
     }
-
-    server.getTestInjector().injectMembers(this);
-    Transport.register(inProcessProtocol);
-    toClose = Collections.synchronizedList(new ArrayList<>());
-
-    admin = accountCreator.admin();
-    user = accountCreator.user1();
-
-    // Evict and reindex accounts in case tests modify them.
-    reindexAccount(admin.id());
-    reindexAccount(user.id());
-
-    adminRestSession = new RestSession(server, admin);
-    userRestSession = new RestSession(server, user);
-    anonymousRestSession = new RestSession(server, null);
-
-    initSsh();
-
-    resourcePrefix =
-        UNSAFE_PROJECT_NAME
-            .matcher(description.getClassName() + "_" + description.getMethodName() + "_")
-            .replaceAll("");
-
-    Context ctx = newRequestContext(admin);
-    atrScope.set(ctx);
-    ProjectInput in = projectInput(description);
-    gApi.projects().create(in);
-    project = Project.nameKey(in.name);
-    if (!classDesc.skipProjectClone()) {
-      testRepo = cloneProject(project, getCloneAsAccount(description));
-    }
-
-    // Set the clock step last, so that the test setup isn't consuming any timestamps after the
-    // clock has been set.
-    setTimeSettings(classDesc.useSystemTime(), classDesc.useClockStep(), classDesc.useTimezone());
-    setTimeSettings(
-        methodDesc.useSystemTime(), methodDesc.useClockStep(), methodDesc.useTimezone());
   }
 
   private static SystemReader setFakeSystemReader(File tempDir) {
@@ -590,13 +696,13 @@
     }
   }
 
-  private TestAccount getCloneAsAccount(Description description) {
+  protected TestAccount getCloneAsAccount(Description description) {
     TestProjectInput ann = description.getAnnotation(TestProjectInput.class);
     return accountCreator.get(ann != null ? ann.cloneAs() : "admin");
   }
 
   /** Generate default project properties based on test description */
-  private ProjectInput projectInput(Description description) {
+  public ProjectInput projectInput(Description description) {
     ProjectInput in = new ProjectInput();
     TestProjectInput ann = description.getAnnotation(TestProjectInput.class);
     in.name = name("project");
@@ -629,7 +735,7 @@
     // Default implementation does nothing.
   }
 
-  private static final Pattern UNSAFE_PROJECT_NAME = Pattern.compile("[^a-zA-Z0-9._/-]+");
+  public static final Pattern UNSAFE_PROJECT_NAME = Pattern.compile("[^a-zA-Z0-9._/-]+");
 
   protected Git git() {
     return testRepo.git();
@@ -945,10 +1051,11 @@
       String subject,
       String fileName,
       String content,
-      String topic)
+      @Nullable String topic)
       throws Exception {
     PushOneCommit push = pushFactory.create(admin.newIdent(), repo, subject, fileName, content);
-    return push.to("refs/for/" + branch + "%topic=" + name(topic));
+    return push.to(
+        "refs/for/" + branch + (Strings.isNullOrEmpty(topic) ? "" : "%topic=" + name(topic)));
   }
 
   protected BranchApi createBranch(BranchNameKey branch) throws Exception {
@@ -1052,7 +1159,13 @@
     return gApi.changes().query(q).get();
   }
 
-  private Context newRequestContext(TestAccount account) {
+  /** Sets up {@code account} as a caller in tests. */
+  public void setRequestScope(TestAccount account) {
+    Context ctx = newRequestContext(account);
+    atrScope.set(ctx);
+  }
+
+  protected Context newRequestContext(TestAccount account) {
     requestScopeOperations.setApiUser(account.id());
     return atrScope.get();
   }
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index ff5bc00..f3881f2 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.NoSuchGroupException;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.GroupCache;
@@ -32,7 +33,6 @@
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -166,6 +166,10 @@
         accounts.get(username), () -> String.format("No TestAccount created for %s ", username));
   }
 
+  public void evict(Account.Id id) {
+    evict(ImmutableSet.of(id));
+  }
+
   public void evict(Collection<Account.Id> ids) {
     accounts.values().removeIf(a -> ids.contains(a.id()));
   }
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index 3d90bf0..f3527f0 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -45,6 +45,7 @@
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.change.ChangeETagComputation;
+import com.google.gerrit.server.change.ReviewerSuggestion;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.git.receive.PluginPushOption;
@@ -108,6 +109,7 @@
 
   private final DynamicMap<ChangeHasOperandFactory> hasOperands;
   private final DynamicMap<ChangeIsOperandFactory> isOperands;
+  private final DynamicMap<ReviewerSuggestion> reviewerSuggestions;
 
   @Inject
   ExtensionRegistry(
@@ -150,7 +152,8 @@
       DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners,
       DynamicMap<ChangeHasOperandFactory> hasOperands,
       DynamicMap<ChangeIsOperandFactory> isOperands,
-      DynamicSet<AttentionSetListener> attentionSetListeners) {
+      DynamicSet<AttentionSetListener> attentionSetListeners,
+      DynamicMap<ReviewerSuggestion> reviewerSuggestions) {
     this.accountIndexedListeners = accountIndexedListeners;
     this.changeIndexedListeners = changeIndexedListeners;
     this.groupIndexedListeners = groupIndexedListeners;
@@ -191,6 +194,7 @@
     this.hasOperands = hasOperands;
     this.isOperands = isOperands;
     this.attentionSetListeners = attentionSetListeners;
+    this.reviewerSuggestions = reviewerSuggestions;
   }
 
   public Registration newRegistration() {
@@ -367,6 +371,10 @@
       return add(reviewerDeletedListeners, reviewerDeletedListener);
     }
 
+    public Registration add(ReviewerSuggestion reviewerSuggestion, String exportName) {
+      return add(reviewerSuggestions, reviewerSuggestion, exportName);
+    }
+
     private <T> Registration add(DynamicSet<T> dynamicSet, T extension) {
       return add(dynamicSet, extension, "gerrit");
     }
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index ae7b084..f4a2e34 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -94,6 +94,7 @@
 import java.util.HashMap;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Optional;
 import java.util.concurrent.BrokenBarrierException;
 import java.util.concurrent.CyclicBarrier;
 import java.util.concurrent.ExecutorService;
@@ -220,7 +221,7 @@
 
     abstract boolean sandboxed();
 
-    abstract boolean skipProjectClone();
+    public abstract boolean skipProjectClone();
 
     abstract boolean useSshAnnotation();
 
@@ -468,10 +469,11 @@
             bind(TestTicker.class).toInstance(testTicker);
           }
         });
-    daemon.setEnableHttpd(desc.httpd());
-    // Assure that SSHD is enabled if HTTPD is not required, otherwise the Gerrit server would not
-    // even start.
-    daemon.setEnableSshd(!desc.httpd() || desc.useSsh());
+    // Assure that HTTPD is enabled if SSHD is not required. If both are disabled the Gerrit server
+    // does not start. Alternatively we could assure that SSHD is enabled if HTTPD is not required,
+    // but this would break the tests at Google, because they don't have support for SSHD.
+    daemon.setEnableHttpd(desc.httpd() || !desc.useSsh());
+    daemon.setEnableSshd(desc.useSsh());
     daemon.setReplica(
         ReplicaUtil.isReplica(baseConfig) || ReplicaUtil.isReplica(desc.buildConfig(baseConfig)));
 
@@ -529,7 +531,7 @@
     daemon.addAdditionalSysModuleForTesting(
         new ReindexProjectsAtStartupModule(), new ReindexGroupsAtStartupModule());
     daemon.start();
-    return new GerritServer(desc, null, createTestInjector(daemon), daemon, null);
+    return new GerritServer(desc, null, createTestInjector(daemon), Optional.of(daemon), null);
   }
 
   private static AbstractIndexModule createIndexModule(
@@ -579,7 +581,8 @@
     }
     System.out.println("Gerrit Server Started");
 
-    return new GerritServer(desc, site, createTestInjector(daemon), daemon, daemonService);
+    return new GerritServer(
+        desc, site, createTestInjector(daemon), Optional.of(daemon), daemonService);
   }
 
   private static void mergeTestConfig(Config cfg) {
@@ -600,6 +603,7 @@
     cfg.setString("gerrit", null, "basePath", "git");
     cfg.setBoolean("sendemail", null, "enable", true);
     cfg.setInt("sendemail", null, "threadPoolSize", 0);
+    cfg.setInt("execution", null, "fanOutThreadPoolSize", 0);
     cfg.setInt("plugins", null, "checkFrequency", 0);
 
     cfg.setInt("sshd", null, "threads", 1);
@@ -628,7 +632,7 @@
             factory(PerCommentOperationsImpl.Factory.class);
             factory(PerDraftCommentOperationsImpl.Factory.class);
             factory(PerRobotCommentOperationsImpl.Factory.class);
-            factory(PushOneCommit.Factory.class);
+            install(new PushOneCommit.Module());
             install(InProcessProtocol.module());
             install(new NoSshModule());
             install(new AsyncReceiveCommitsModule());
@@ -669,17 +673,17 @@
   private final Description desc;
   private final Path sitePath;
 
-  private Daemon daemon;
-  private ExecutorService daemonService;
-  private Injector testInjector;
-  private String url;
-  private InetSocketAddress httpAddress;
+  private final Optional<Daemon> daemon;
+  private final ExecutorService daemonService;
+  protected final Injector testInjector;
+  private final String url;
+  private final Optional<InetSocketAddress> httpAddress;
 
-  private GerritServer(
+  protected GerritServer(
       Description desc,
       @Nullable Path sitePath,
       Injector testInjector,
-      Daemon daemon,
+      Optional<Daemon> daemon,
       @Nullable ExecutorService daemonService) {
     this.desc = requireNonNull(desc);
     this.sitePath = sitePath;
@@ -689,15 +693,20 @@
 
     Config cfg = testInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
     url = cfg.getString("gerrit", null, "canonicalWebUrl");
-    URI uri = URI.create(url);
-    httpAddress = new InetSocketAddress(uri.getHost(), uri.getPort());
+
+    if (daemon.isPresent()) {
+      URI uri = URI.create(url);
+      httpAddress = Optional.of(new InetSocketAddress(uri.getHost(), uri.getPort()));
+    } else {
+      httpAddress = Optional.empty();
+    }
   }
 
   public String getUrl() {
     return url;
   }
 
-  InetSocketAddress getHttpAddress() {
+  Optional<InetSocketAddress> getHttpAddress() {
     return httpAddress;
   }
 
@@ -705,8 +714,8 @@
     return testInjector;
   }
 
-  public Injector getHttpdInjector() {
-    return daemon.getHttpdInjector();
+  public Optional<Injector> getHttpdInjector() {
+    return daemon.map(Daemon::getHttpdInjector);
   }
 
   Description getDescription() {
@@ -727,7 +736,7 @@
     }
 
     server.close();
-    server.daemon.stop();
+    server.daemon.ifPresent(Daemon::stop);
     return start(server.desc, cfg, site, null, null, null, inMemoryRepoManager);
   }
 
@@ -744,7 +753,7 @@
     }
 
     server.close();
-    server.daemon.stop();
+    server.daemon.ifPresent(Daemon::stop);
     return start(server.desc, cfg, site, testSysModule, null, testSshModule, inMemoryRepoManager);
   }
 
@@ -754,7 +763,7 @@
 
   @Override
   public void close() throws Exception {
-    daemon.getLifecycleManager().stop();
+    daemon.ifPresent(d -> d.getLifecycleManager().stop());
     if (daemonService != null) {
       System.out.println("Gerrit Server Shutdown");
       daemonService.shutdownNow();
@@ -773,6 +782,6 @@
   }
 
   public boolean isReplica() {
-    return daemon.isReplica();
+    return daemon.map(Daemon::isReplica).orElse(false);
   }
 }
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index 9f38fcb..a61fa46 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -81,6 +82,15 @@
           + "\n"
           + PATCH_FILE_ONLY;
 
+  public static class Module extends FactoryModule {
+    @Override
+    protected void configure() {
+      factory(PushOneCommit.Factory.class);
+
+      factory(PushOneCommit.Result.Factory.class);
+    }
+  }
+
   public interface Factory {
     PushOneCommit create(PersonIdent i, TestRepository<?> testRepo);
 
@@ -148,11 +158,10 @@
     return String.format("%040x", CHANGE_ID_COUNTER.incrementAndGet());
   }
 
-  private final ChangeNotes.Factory notesFactory;
-  private final ApprovalsUtil approvalsUtil;
-  private final Provider<InternalChangeQuery> queryProvider;
   private final TestRepository<?> testRepo;
 
+  private final Result.Factory pushResultFactory;
+
   private final String subject;
   private final Map<String, String> files;
   private String changeId;
@@ -164,68 +173,49 @@
 
   @AssistedInject
   PushOneCommit(
-      ChangeNotes.Factory notesFactory,
-      ApprovalsUtil approvalsUtil,
-      Provider<InternalChangeQuery> queryProvider,
+      Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo)
       throws Exception {
-    this(notesFactory, approvalsUtil, queryProvider, i, testRepo, SUBJECT, FILE_NAME, FILE_CONTENT);
+    this(pushResultFactory, i, testRepo, SUBJECT, FILE_NAME, FILE_CONTENT);
   }
 
   @AssistedInject
   PushOneCommit(
-      ChangeNotes.Factory notesFactory,
-      ApprovalsUtil approvalsUtil,
-      Provider<InternalChangeQuery> queryProvider,
+      Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
       @Assisted("changeId") String changeId)
       throws Exception {
-    this(
-        notesFactory,
-        approvalsUtil,
-        queryProvider,
-        i,
-        testRepo,
-        SUBJECT,
-        FILE_NAME,
-        FILE_CONTENT,
-        changeId);
+    this(pushResultFactory, i, testRepo, SUBJECT, FILE_NAME, FILE_CONTENT, changeId);
   }
 
   @AssistedInject
   PushOneCommit(
-      ChangeNotes.Factory notesFactory,
-      ApprovalsUtil approvalsUtil,
-      Provider<InternalChangeQuery> queryProvider,
+      Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
       @Assisted("subject") String subject,
       @Assisted("fileName") String fileName,
       @Assisted("content") String content)
       throws Exception {
-    this(notesFactory, approvalsUtil, queryProvider, i, testRepo, subject, fileName, content, null);
+    this(pushResultFactory, i, testRepo, subject, fileName, content, null);
   }
 
   @AssistedInject
   PushOneCommit(
-      ChangeNotes.Factory notesFactory,
-      ApprovalsUtil approvalsUtil,
-      Provider<InternalChangeQuery> queryProvider,
+      Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
       @Assisted String subject,
       @Assisted Map<String, String> files)
       throws Exception {
-    this(notesFactory, approvalsUtil, queryProvider, i, testRepo, subject, files, null);
+    this(pushResultFactory, i, testRepo, subject, files, null);
   }
 
   @AssistedInject
   PushOneCommit(
-      ChangeNotes.Factory notesFactory,
-      ApprovalsUtil approvalsUtil,
-      Provider<InternalChangeQuery> queryProvider,
+      Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
       @Assisted("subject") String subject,
@@ -233,22 +223,12 @@
       @Assisted("content") String content,
       @Nullable @Assisted("changeId") String changeId)
       throws Exception {
-    this(
-        notesFactory,
-        approvalsUtil,
-        queryProvider,
-        i,
-        testRepo,
-        subject,
-        ImmutableMap.of(fileName, content),
-        changeId);
+    this(pushResultFactory, i, testRepo, subject, ImmutableMap.of(fileName, content), changeId);
   }
 
   @AssistedInject
   PushOneCommit(
-      ChangeNotes.Factory notesFactory,
-      ApprovalsUtil approvalsUtil,
-      Provider<InternalChangeQuery> queryProvider,
+      Result.Factory pushResultFactory,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
       @Assisted("subject") String subject,
@@ -256,12 +236,10 @@
       @Nullable @Assisted("changeId") String changeId)
       throws Exception {
     this.testRepo = testRepo;
-    this.notesFactory = notesFactory;
-    this.approvalsUtil = approvalsUtil;
-    this.queryProvider = queryProvider;
     this.subject = subject;
     this.files = files;
     this.changeId = changeId;
+    this.pushResultFactory = pushResultFactory;
     if (changeId != null) {
       commitBuilder = testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
     } else {
@@ -283,7 +261,7 @@
   }
 
   @CanIgnoreReturnValue
-  public PushOneCommit setTopLevelTreeId(ObjectId treeId) throws Exception {
+  public PushOneCommit setTopLevelTreeId(ObjectId treeId) {
     commitBuilder.setTopLevelTree(treeId);
     return this;
   }
@@ -294,7 +272,7 @@
     return this;
   }
 
-  public PushOneCommit noParent() throws Exception {
+  public PushOneCommit noParent() {
     commitBuilder.noParents();
     return this;
   }
@@ -374,7 +352,13 @@
       }
       tagCommand.call();
     }
-    return new Result(ref, pushHead(testRepo, ref, tag != null, force, pushOptions), c, subject);
+    return pushResultFactory.create(
+        ref,
+        subject,
+        changeId,
+        pushHead(testRepo, ref, tag != null, force, pushOptions),
+        c,
+        pushOptions);
   }
 
   public void setTag(Tag tag) {
@@ -397,17 +381,51 @@
     commitBuilder.noParents();
   }
 
-  public class Result {
+  public static class Result {
+
+    public interface Factory {
+      Result create(
+          @Assisted("ref") String ref,
+          @Assisted("subject") String subject,
+          @Assisted("changeId") String changeId,
+          @Nullable PushResult resSubj,
+          @Nullable RevCommit commit,
+          @Nullable List<String> pushOptions);
+    }
+
     private final String ref;
     private final PushResult result;
     private final RevCommit commit;
     private final String resSubj;
 
-    private Result(String ref, PushResult resSubj, RevCommit commit, String subject) {
+    private final String changeId;
+
+    private final ChangeNotes.Factory notesFactory;
+    private final ApprovalsUtil approvalsUtil;
+    private final Provider<InternalChangeQuery> queryProvider;
+
+    private final List<String> pushOptions;
+
+    @AssistedInject
+    public Result(
+        ChangeNotes.Factory notesFactory,
+        ApprovalsUtil approvalsUtil,
+        Provider<InternalChangeQuery> queryProvider,
+        @Assisted("ref") String ref,
+        @Assisted("subject") String subject,
+        @Assisted("changeId") String changeId,
+        @Assisted @Nullable PushResult resSubj,
+        @Assisted @Nullable RevCommit commit,
+        @Assisted @Nullable List<String> pushOptions) {
       this.ref = ref;
       this.result = resSubj;
       this.commit = commit;
       this.resSubj = subject;
+      this.changeId = changeId;
+      this.notesFactory = notesFactory;
+      this.approvalsUtil = approvalsUtil;
+      this.queryProvider = queryProvider;
+      this.pushOptions = pushOptions;
     }
 
     public ChangeData getChange() {
@@ -431,7 +449,7 @@
     }
 
     public void assertPushOptions(List<String> pushOptions) {
-      assertEquals(pushOptions, getPushOptions());
+      assertEquals(pushOptions, this.pushOptions);
     }
 
     public void assertChange(
diff --git a/java/com/google/gerrit/acceptance/RestResponse.java b/java/com/google/gerrit/acceptance/RestResponse.java
index a045d80..a5ce78e 100644
--- a/java/com/google/gerrit/acceptance/RestResponse.java
+++ b/java/com/google/gerrit/acceptance/RestResponse.java
@@ -100,4 +100,9 @@
     assertStatus(SC_MOVED_TEMPORARILY);
     assertThat(URI.create(getHeader("Location")).getPath()).isEqualTo(path);
   }
+
+  public void assertTemporaryRedirectUri(String uri) throws Exception {
+    assertStatus(SC_MOVED_TEMPORARILY);
+    assertThat(getHeader("Location")).isEqualTo(uri);
+  }
 }
diff --git a/java/com/google/gerrit/acceptance/RestSession.java b/java/com/google/gerrit/acceptance/RestSession.java
index 863e516..4264cd2 100644
--- a/java/com/google/gerrit/acceptance/RestSession.java
+++ b/java/com/google/gerrit/acceptance/RestSession.java
@@ -127,7 +127,7 @@
     return execute(delete);
   }
 
-  private String getUrl(String endPoint) {
+  public String getUrl(String endPoint) {
     return url + (account != null ? "/a" : "") + endPoint;
   }
 }
diff --git a/java/com/google/gerrit/acceptance/TestAccount.java b/java/com/google/gerrit/acceptance/TestAccount.java
index d5908f4..67d8a05 100644
--- a/java/com/google/gerrit/acceptance/TestAccount.java
+++ b/java/com/google/gerrit/acceptance/TestAccount.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance;
 
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.auto.value.AutoValue;
@@ -42,7 +43,7 @@
     return names(Arrays.asList(accounts));
   }
 
-  static TestAccount create(
+  public static TestAccount create(
       Account.Id id,
       @Nullable String username,
       @Nullable String email,
@@ -78,7 +79,8 @@
   }
 
   public String getHttpUrl(GerritServer server) {
-    InetSocketAddress addr = server.getHttpAddress();
+    checkState(server.getHttpAddress().isPresent(), "GerritServer must have httpAddress");
+    InetSocketAddress addr = server.getHttpAddress().get();
     return new URIBuilder()
         .setScheme("http")
         .setUserInfo(username(), httpPassword())
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
index c6457a4..edbb1ee 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountDelta;
 import com.google.gerrit.server.account.AccountState;
@@ -28,7 +29,6 @@
 import com.google.gerrit.server.account.AccountsUpdate.ConfigureDeltaFromState;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.Optional;
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
index 5efcfc6..dbcfceb 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeInserter;
@@ -41,7 +42,6 @@
 import com.google.gerrit.server.edit.tree.TreeModification;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.UpdateException;
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerDraftCommentOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/PerDraftCommentOperationsImpl.java
index db264c5..3d53816 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/PerDraftCommentOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerDraftCommentOperationsImpl.java
@@ -18,7 +18,7 @@
 import static com.google.gerrit.acceptance.testsuite.change.PerCommentOperationsImpl.toTestComment;
 
 import com.google.gerrit.entities.HumanComment;
-import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -30,10 +30,9 @@
  * the separation between interface and implementation to enhance clarity.
  */
 public class PerDraftCommentOperationsImpl implements PerDraftCommentOperations {
-  private final CommentsUtil commentsUtil;
-
   private final ChangeNotes changeNotes;
   private final String commentUuid;
+  private final DraftCommentsReader draftCommentsReader;
 
   public interface Factory {
     PerDraftCommentOperationsImpl create(ChangeNotes changeNotes, String commentUuid);
@@ -41,16 +40,18 @@
 
   @Inject
   public PerDraftCommentOperationsImpl(
-      CommentsUtil commentsUtil, @Assisted ChangeNotes changeNotes, @Assisted String commentUuid) {
-    this.commentsUtil = commentsUtil;
+      DraftCommentsReader draftCommentsReader,
+      @Assisted ChangeNotes changeNotes,
+      @Assisted String commentUuid) {
     this.changeNotes = changeNotes;
     this.commentUuid = commentUuid;
+    this.draftCommentsReader = draftCommentsReader;
   }
 
   @Override
   public TestHumanComment get() {
     HumanComment comment =
-        commentsUtil.draftByChange(changeNotes).stream()
+        draftCommentsReader.getDraftsByChangeForAllAuthors(changeNotes).stream()
             .filter(foundComment -> foundComment.key.uuid.equals(commentUuid))
             .collect(onlyElement());
     return toTestComment(comment);
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
index a37c2ba..5f8f3e7 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
@@ -21,13 +21,13 @@
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.GroupUuid;
 import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupCreation;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.Optional;
diff --git a/java/com/google/gerrit/common/IoUtil.java b/java/com/google/gerrit/common/IoUtil.java
index e691025..e51a6e5 100644
--- a/java/com/google/gerrit/common/IoUtil.java
+++ b/java/com/google/gerrit/common/IoUtil.java
@@ -14,19 +14,9 @@
 
 package com.google.gerrit.common;
 
-import static com.google.gerrit.launcher.GerritLauncher.GerritClassLoader;
-
-import com.google.common.collect.Sets;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.nio.file.Path;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Set;
 
 public final class IoUtil {
   public static void copyWithThread(InputStream src, OutputStream dst) {
@@ -63,40 +53,5 @@
     }.start();
   }
 
-  public static void loadJARs(Collection<Path> jars) {
-    if (jars.isEmpty()) {
-      return;
-    }
-
-    ClassLoader cl = IoUtil.class.getClassLoader();
-    if (!(cl instanceof GerritClassLoader)) {
-      throw noAddURL("Not loaded by GerritClassLoader", null);
-    }
-
-    @SuppressWarnings("resource") // Leave open so classes can be loaded.
-    GerritClassLoader gerritClassLoader = (GerritClassLoader) cl;
-
-    Set<URL> have = Sets.newHashSet(Arrays.asList(gerritClassLoader.getURLs()));
-    for (Path path : jars) {
-      try {
-        URL url = path.toUri().toURL();
-        if (have.add(url)) {
-          gerritClassLoader.addURL(url);
-        }
-      } catch (MalformedURLException | IllegalArgumentException e) {
-        throw noAddURL("addURL " + path + " failed", e);
-      }
-    }
-  }
-
-  public static void loadJARs(Path jar) {
-    loadJARs(Collections.singleton(jar));
-  }
-
-  private static UnsupportedOperationException noAddURL(String m, Throwable why) {
-    String prefix = "Cannot extend classpath: ";
-    return new UnsupportedOperationException(prefix + m, why);
-  }
-
   private IoUtil() {}
 }
diff --git a/java/com/google/gerrit/common/JarUtil.java b/java/com/google/gerrit/common/JarUtil.java
new file mode 100644
index 0000000..b88a7ab
--- /dev/null
+++ b/java/com/google/gerrit/common/JarUtil.java
@@ -0,0 +1,65 @@
+// 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.common;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.launcher.GerritLauncher.GerritClassLoader;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Set;
+
+/** Provides util methods for dynamic loading jars */
+public final class JarUtil {
+  public static void loadJars(Collection<Path> jars) {
+    if (jars.isEmpty()) {
+      return;
+    }
+
+    ClassLoader cl = JarUtil.class.getClassLoader();
+    if (!(cl instanceof GerritClassLoader)) {
+      throw noAddURL("Not loaded by GerritClassLoader", null);
+    }
+
+    @SuppressWarnings("resource") // Leave open so classes can be loaded.
+    GerritClassLoader gerritClassLoader = (GerritClassLoader) cl;
+
+    Set<URL> have = Sets.newHashSet(Arrays.asList(gerritClassLoader.getURLs()));
+    for (Path path : jars) {
+      try {
+        URL url = path.toUri().toURL();
+        if (have.add(url)) {
+          gerritClassLoader.addURL(url);
+        }
+      } catch (MalformedURLException | IllegalArgumentException e) {
+        throw noAddURL("addURL " + path + " failed", e);
+      }
+    }
+  }
+
+  public static void loadJars(Path jar) {
+    loadJars(Collections.singleton(jar));
+  }
+
+  private static UnsupportedOperationException noAddURL(String m, Throwable why) {
+    String prefix = "Cannot extend classpath: ";
+    return new UnsupportedOperationException(prefix + m, why);
+  }
+
+  private JarUtil() {}
+}
diff --git a/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java b/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java
index fa9b139..95df5be 100644
--- a/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java
+++ b/java/com/google/gerrit/common/SiteLibraryLoaderUtil.java
@@ -35,7 +35,7 @@
   public static void loadSiteLib(Path libdir) {
     try {
       List<Path> jars = listJars(libdir);
-      IoUtil.loadJARs(jars);
+      JarUtil.loadJars(jars);
       logger.atFine().log("Loaded site libraries: %s", lazy(() -> jarList(jars)));
     } catch (IOException e) {
       logger.atSevere().withCause(e).log("Error scanning lib directory %s", libdir);
diff --git a/java/com/google/gerrit/common/UsedAt.java b/java/com/google/gerrit/common/UsedAt.java
index 46b43c6..5ea5177 100644
--- a/java/com/google/gerrit/common/UsedAt.java
+++ b/java/com/google/gerrit/common/UsedAt.java
@@ -43,8 +43,8 @@
     PLUGIN_DELETE_PROJECT,
     PLUGIN_HIGH_AVAILABILITY,
     PLUGIN_MULTI_SITE,
-    PLUGIN_SERVICEUSER,
     PLUGIN_PULL_REPLICATION,
+    PLUGIN_SERVICEUSER,
     PLUGIN_WEBSESSION_FLATFILE,
     MODULE_GIT_REFS_FILTER,
     MODULE_VIRTUALHOST
diff --git a/java/com/google/gerrit/common/data/FilenameComparator.java b/java/com/google/gerrit/common/data/FilenameComparator.java
index 0b188df..e1c763c0 100644
--- a/java/com/google/gerrit/common/data/FilenameComparator.java
+++ b/java/com/google/gerrit/common/data/FilenameComparator.java
@@ -30,6 +30,13 @@
 
   @Override
   public int compare(String path1, String path2) {
+    if (Patch.PATCHSET_LEVEL.equals(path1) && Patch.PATCHSET_LEVEL.equals(path2)) {
+      return 0;
+    } else if (Patch.PATCHSET_LEVEL.equals(path1)) {
+      return -1;
+    } else if (Patch.PATCHSET_LEVEL.equals(path2)) {
+      return 1;
+    }
     if (Patch.COMMIT_MSG.equals(path1) && Patch.COMMIT_MSG.equals(path2)) {
       return 0;
     } else if (Patch.COMMIT_MSG.equals(path1)) {
diff --git a/java/com/google/gerrit/common/data/GlobalCapability.java b/java/com/google/gerrit/common/data/GlobalCapability.java
index 23151c2..c957986 100644
--- a/java/com/google/gerrit/common/data/GlobalCapability.java
+++ b/java/com/google/gerrit/common/data/GlobalCapability.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.common.data;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.PermissionRange;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
@@ -172,8 +172,8 @@
   }
 
   /** Returns all valid capability names. */
-  public static Collection<String> getAllNames() {
-    return Collections.unmodifiableList(NAMES_ALL);
+  public static ImmutableList<String> getAllNames() {
+    return ImmutableList.copyOf(NAMES_ALL);
   }
 
   /** Returns true if the name is recognized as a capability name. */
diff --git a/java/com/google/gerrit/entities/Account.java b/java/com/google/gerrit/entities/Account.java
index 699acc0..52ad0a9 100644
--- a/java/com/google/gerrit/entities/Account.java
+++ b/java/com/google/gerrit/entities/Account.java
@@ -162,7 +162,7 @@
   /**
    * Create a new account.
    *
-   * @param newId unique id, see {@link com.google.gerrit.server.notedb.Sequences#nextAccountId()}.
+   * @param newId unique id, see Sequences#nextAccountId().
    * @param registeredOn when the account was registered.
    */
   public static Account.Builder builder(Account.Id newId, Instant registeredOn) {
diff --git a/java/com/google/gerrit/entities/ChangeMessage.java b/java/com/google/gerrit/entities/ChangeMessage.java
index d01f0af..5a35af3 100644
--- a/java/com/google/gerrit/entities/ChangeMessage.java
+++ b/java/com/google/gerrit/entities/ChangeMessage.java
@@ -96,6 +96,7 @@
   }
 
   /** If null, the message was written 'by the Gerrit system'. */
+  @Nullable
   public Account.Id getAuthor() {
     return author;
   }
diff --git a/java/com/google/gerrit/entities/ParentCommitData.java b/java/com/google/gerrit/entities/ParentCommitData.java
new file mode 100644
index 0000000..e1fce30
--- /dev/null
+++ b/java/com/google/gerrit/entities/ParentCommitData.java
@@ -0,0 +1,94 @@
+// 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.entities;
+
+import com.google.auto.value.AutoValue;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Information about the parent of a revision patch-set. The parent can either be a merged commit of
+ * the target branch, or a patch-set of another gerrit change.
+ */
+@AutoValue
+public abstract class ParentCommitData {
+
+  /**
+   * The name of the target branch into which the current commit should be merged. Set if the change
+   * is based on a merged commit in the target branch.
+   *
+   * <p>This field is {@link Optional#empty()} if this information is not available for the current
+   * commit, or if the parent commit belongs to a patch-set of another Gerrit change.
+   */
+  public abstract Optional<String> branchName();
+
+  /**
+   * The commit SHA-1 of the parent commit, or {@link Optional#empty} if there is no parent (i.e.
+   * current commit is a root commit).
+   */
+  public abstract Optional<ObjectId> commitId();
+
+  /** Whether the parent commit is merged in the target branch {@link #branchName()}. */
+  public abstract Boolean isMergedInTargetBranch();
+
+  /**
+   * Change key of the parent commit. Only set if the parent commit is a patch-set of another gerrit
+   * change.
+   */
+  public abstract Optional<Change.Key> changeKey();
+
+  /**
+   * Change number of the parent commit. Only set if the parent commit is a patch-set of another
+   * gerrit change.
+   */
+  public abstract Optional<Integer> changeNumber();
+
+  /**
+   * patch-set number of the parent commit. Only set if the parent commit is a patch-set of another
+   * gerrit change.
+   */
+  public abstract Optional<Integer> patchSetNumber();
+
+  /**
+   * Change status of the parent commit. Only set if the parent commit is a patch-set of another
+   * gerrit change.
+   */
+  public abstract Optional<Change.Status> changeStatus();
+
+  public static Builder builder() {
+    return new AutoValue_ParentCommitData.Builder().isMergedInTargetBranch(false);
+  }
+
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder branchName(Optional<String> branchName);
+
+    public abstract Builder commitId(Optional<ObjectId> commitId);
+
+    public abstract Builder isMergedInTargetBranch(Boolean isMerged);
+
+    public abstract Builder changeKey(Optional<Change.Key> changeKey);
+
+    public abstract Builder changeNumber(Optional<Integer> changeNumber);
+
+    public abstract Builder patchSetNumber(Optional<Integer> patchSetNumber);
+
+    public abstract Builder changeStatus(Optional<Change.Status> changeStatus);
+
+    public abstract ParentCommitData autoBuild();
+  }
+}
diff --git a/java/com/google/gerrit/entities/PatchSet.java b/java/com/google/gerrit/entities/PatchSet.java
index 8784437..e8759fa 100644
--- a/java/com/google/gerrit/entities/PatchSet.java
+++ b/java/com/google/gerrit/entities/PatchSet.java
@@ -168,6 +168,10 @@
 
     public abstract Optional<ObjectId> commitId();
 
+    public abstract Builder branch(Optional<String> branch);
+
+    public abstract Builder branch(String branch);
+
     public abstract Builder uploader(Account.Id uploader);
 
     public abstract Builder realUploader(Account.Id realUploader);
@@ -204,6 +208,12 @@
   public abstract ObjectId commitId();
 
   /**
+   * Name of the target branch where this patch-set should be merged into. If the change is moved,
+   * different patch-sets will have different target branches.
+   */
+  public abstract Optional<String> branch();
+
+  /**
    * Account that uploaded the patch set.
    *
    * <p>If the upload was done on behalf of another user, the impersonated user on whom's behalf the
diff --git a/java/com/google/gerrit/entities/Permission.java b/java/com/google/gerrit/entities/Permission.java
index 2a34579..0e959e7 100644
--- a/java/com/google/gerrit/entities/Permission.java
+++ b/java/com/google/gerrit/entities/Permission.java
@@ -36,6 +36,7 @@
   public static final String DELETE = "delete";
   public static final String DELETE_CHANGES = "deleteChanges";
   public static final String DELETE_OWN_CHANGES = "deleteOwnChanges";
+  public static final String EDIT_CUSTOM_KEYED_VALUES = "editCustomKeyedValues";
   public static final String EDIT_HASHTAGS = "editHashtags";
   public static final String EDIT_TOPIC_NAME = "editTopicName";
   public static final String FORGE_AUTHOR = "forgeAuthor";
@@ -73,6 +74,7 @@
     NAMES_LC.add(DELETE.toLowerCase(Locale.US));
     NAMES_LC.add(DELETE_CHANGES.toLowerCase(Locale.US));
     NAMES_LC.add(DELETE_OWN_CHANGES.toLowerCase(Locale.US));
+    NAMES_LC.add(EDIT_CUSTOM_KEYED_VALUES.toLowerCase(Locale.US));
     NAMES_LC.add(EDIT_HASHTAGS.toLowerCase(Locale.US));
     NAMES_LC.add(EDIT_TOPIC_NAME.toLowerCase(Locale.US));
     NAMES_LC.add(FORGE_AUTHOR.toLowerCase(Locale.US));
diff --git a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
index b32f09a..a3b4abf 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
@@ -44,6 +44,7 @@
             .setUploaderAccountId(accountIdConverter.toProto(patchSet.uploader()))
             .setRealUploaderAccountId(accountIdConverter.toProto(patchSet.realUploader()))
             .setCreatedOn(patchSet.createdOn().toEpochMilli());
+    patchSet.branch().ifPresent(builder::setBranch);
     List<String> groups = patchSet.groups();
     if (!groups.isEmpty()) {
       builder.setGroups(PatchSet.joinGroups(groups));
@@ -66,6 +67,9 @@
     if (proto.hasDescription()) {
       builder.description(proto.getDescription());
     }
+    if (proto.hasBranch()) {
+      builder.branch(proto.getBranch());
+    }
 
     // The following fields used to theoretically be nullable in PatchSet, but in practice no
     // production codepath should have ever serialized an instance that was missing one of these
diff --git a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
index 9c9c282..0d019aa 100644
--- a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
+++ b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
@@ -124,6 +124,8 @@
    */
   String setHttpPassword(String httpPassword) throws RestApiException;
 
+  void delete() throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -327,5 +329,10 @@
     public String setHttpPassword(String httpPassword) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void delete() throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java b/java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java
index cf114df..b843789 100644
--- a/java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java
@@ -44,4 +44,11 @@
   @Nullable public AccountInput author;
 
   @Nullable public List<ListChangesOption> responseFormatOptions;
+
+  /**
+   * If {@code true}, the revision will be amended by the patch. This will use the tree of the
+   * revision, apply the patch and create a new commit whose tree is the resulting tree of the
+   * operation and whose parent(s) are the parent(s) of the revision.
+   */
+  @Nullable public Boolean amend;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index ef61b68..14e1805 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.api.changes;
 
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -132,7 +133,7 @@
   /**
    * Create a new change that reverts this change.
    *
-   * @see Changes#id(int)
+   * @see Changes#id(String, int)
    */
   default ChangeApi revert() throws RestApiException {
     return revert(new RevertInput());
@@ -141,7 +142,7 @@
   /**
    * Create a new change that reverts this change.
    *
-   * @see Changes#id(int)
+   * @see Changes#id(String, int)
    */
   ChangeApi revert(RevertInput in) throws RestApiException;
 
@@ -337,6 +338,16 @@
    */
   Set<String> getHashtags() throws RestApiException;
 
+  /** Set custom keyed values on a change */
+  void setCustomKeyedValues(CustomKeyedValuesInput input) throws RestApiException;
+
+  /**
+   * Gets the custom keyed values on a change.
+   *
+   * @return customKeyedValues
+   */
+  ImmutableMap<String, String> getCustomKeyedValues() throws RestApiException;
+
   /**
    * Manage the attention set.
    *
@@ -720,6 +731,16 @@
     }
 
     @Override
+    public void setCustomKeyedValues(CustomKeyedValuesInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ImmutableMap<String, String> getCustomKeyedValues() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public AttentionSetApi attention(String id) throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/Changes.java b/java/com/google/gerrit/extensions/api/changes/Changes.java
index d8741f5..9f70776 100644
--- a/java/com/google/gerrit/extensions/api/changes/Changes.java
+++ b/java/com/google/gerrit/extensions/api/changes/Changes.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
@@ -30,21 +31,21 @@
   /**
    * Look up a change by numeric ID.
    *
-   * <p><strong>Note:</strong> This method eagerly reads the change. Methods that mutate the change
-   * do not necessarily re-read the change. Therefore, calling a getter method on an instance after
-   * calling a mutation method on that same instance is not guaranteed to reflect the mutation. It
-   * is not recommended to store references to {@code ChangeApi} instances.
+   * <p><strong>Note:</strong> Change number is not guaranteed to unambiguously identify a change.
    *
+   * @see #id(String, int)
+   * @deprecated in favor of {@link #id(String, int)}
    * @param id change number.
    * @return API for accessing the change.
    * @throws RestApiException if an error occurred.
    */
+  @Deprecated(since = "3.9")
   ChangeApi id(int id) throws RestApiException;
 
   /**
    * Look up a change by string ID.
    *
-   * @see #id(int)
+   * @see #id(String, int)
    * @param id any identifier supported by the REST API, including change number, Change-Id, or
    *     project~branch~Change-Id triplet.
    * @return API for accessing the change.
@@ -55,16 +56,23 @@
   /**
    * Look up a change by project, branch, and change ID.
    *
-   * @see #id(int)
+   * @see #id(String, int)
    */
   ChangeApi id(String project, String branch, String id) throws RestApiException;
 
   /**
    * Look up a change by project and numeric ID.
    *
+   * <p><strong>Note:</strong> This method eagerly reads the change. Methods that mutate the change
+   * do not necessarily re-read the change. Therefore, calling a getter method on an instance after
+   * calling a mutation method on that same instance is not guaranteed to reflect the mutation. It
+   * is not recommended to store references to {@code ChangeApi} instances. Also note that the
+   * change numeric id without a project name parameter may fail to identify a unique change
+   * element, because the potential conflict with other changes imported from Gerrit instances with
+   * a different Server-Id.
+   *
    * @param project project name.
    * @param id change number.
-   * @see #id(int)
    */
   ChangeApi id(String project, int id) throws RestApiException;
 
@@ -81,6 +89,7 @@
     private int limit;
     private int start;
     private boolean isNoLimit;
+    private boolean allowIncompleteResults;
     private Set<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
     private ListMultimap<String, String> pluginOptions = ArrayListMultimap.create();
 
@@ -106,6 +115,12 @@
       return this;
     }
 
+    @UsedAt(UsedAt.Project.GOOGLE)
+    public QueryRequest withAllowIncompleteResults(boolean allow) {
+      this.allowIncompleteResults = allow;
+      return this;
+    }
+
     /** Set an option on the request, appending to existing options. */
     public QueryRequest withOption(ListChangesOption options) {
       this.options.add(options);
@@ -152,6 +167,11 @@
       return start;
     }
 
+    @UsedAt(UsedAt.Project.GOOGLE)
+    public boolean getAllowIncompleteResults() {
+      return allowIncompleteResults;
+    }
+
     public Set<ListChangesOption> getOptions() {
       return options;
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
index 232b2b5..646d551 100644
--- a/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
@@ -32,4 +32,5 @@
   public String topic;
   public boolean allowEmpty;
   public Map<String, String> validationOptions;
+  public String committerEmail;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/CustomKeyedValuesInput.java b/java/com/google/gerrit/extensions/api/changes/CustomKeyedValuesInput.java
new file mode 100644
index 0000000..740df21
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/CustomKeyedValuesInput.java
@@ -0,0 +1,35 @@
+// 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.extensions.api.changes;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+import java.util.Map;
+import java.util.Set;
+
+public class CustomKeyedValuesInput {
+  @DefaultInput public Map<String, String> add;
+  public Set<String> remove;
+
+  public CustomKeyedValuesInput() {}
+
+  public CustomKeyedValuesInput(Map<String, String> add) {
+    this.add = add;
+  }
+
+  public CustomKeyedValuesInput(Map<String, String> add, Set<String> remove) {
+    this(add);
+    this.remove = remove;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
index a85bc73..42dea8d 100644
--- a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
@@ -20,6 +20,13 @@
   public String base;
 
   /**
+   * {@code strategy} name of the merge strategy.
+   *
+   * @see org.eclipse.jgit.merge.MergeStrategy
+   */
+  public String strategy;
+
+  /**
    * Whether the rebase should succeed if there are conflicts.
    *
    * <p>If there are conflicts the file contents of the rebased change contain git conflict markers
@@ -44,4 +51,12 @@
   public boolean onBehalfOfUploader;
 
   public Map<String, String> validationOptions;
+
+  /**
+   * Rebase will be committed using this email address. Only the registered emails of the calling
+   * user or uploader (when onBehalfOfUploader is true) are considered valid.
+   *
+   * <p>This option is not supported when rebasing a chain.
+   */
+  public String committerEmail;
 }
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
index 8bfe468..98807cb 100644
--- a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
@@ -18,7 +18,9 @@
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.FixSuggestionInfo;
 import com.google.gerrit.extensions.restapi.DefaultInput;
@@ -96,6 +98,8 @@
    */
   public boolean ignoreAutomaticAttentionSetRules;
 
+  @Nullable public List<ListChangesOption> responseFormatOptions;
+
   public enum DraftHandling {
     /** Leave pending drafts alone. */
     KEEP,
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewResult.java b/java/com/google/gerrit/extensions/api/changes/ReviewResult.java
index 95bea5b..bd22ca8 100644
--- a/java/com/google/gerrit/extensions/api/changes/ReviewResult.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewResult.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.api.changes;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import java.util.Map;
 
 /** Result object representing the outcome of a review request. */
@@ -38,4 +39,7 @@
 
   /** Error message for non-200 responses. */
   @Nullable public String error;
+
+  /** Change after applying the update. */
+  @Nullable public ChangeInfo changeInfo;
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/BranchApi.java b/java/com/google/gerrit/extensions/api/projects/BranchApi.java
index a1f7327..90f1f3f 100644
--- a/java/com/google/gerrit/extensions/api/projects/BranchApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/BranchApi.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.gerrit.extensions.api.changes.ChangeApi.SuggestedReviewersRequest;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -31,6 +32,16 @@
 
   List<ReflogEntryInfo> reflog() throws RestApiException;
 
+  SuggestedReviewersRequest suggestReviewers() throws RestApiException;
+
+  default SuggestedReviewersRequest suggestReviewers(String query) throws RestApiException {
+    return suggestReviewers().withQuery(query);
+  }
+
+  default SuggestedReviewersRequest suggestCcs(String query) throws RestApiException {
+    return suggestReviewers().forCc().withQuery(query);
+  }
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -42,6 +53,16 @@
     }
 
     @Override
+    public SuggestedReviewersRequest suggestReviewers() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public SuggestedReviewersRequest suggestReviewers(String query) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public BranchInfo get() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index f6408b6..370068e 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -71,6 +71,7 @@
     protected int start;
     protected String substring;
     protected String regex;
+    protected String nextPageToken;
 
     public abstract List<T> get() throws RestApiException;
 
@@ -84,6 +85,11 @@
       return this;
     }
 
+    public ListRefsRequest<T> withNextPageToken(String token) {
+      this.nextPageToken = token;
+      return this;
+    }
+
     public ListRefsRequest<T> withSubstring(String substring) {
       this.substring = substring;
       return this;
@@ -102,6 +108,10 @@
       return start;
     }
 
+    public String getNextPageToken() {
+      return nextPageToken;
+    }
+
     public String getSubstring() {
       return substring;
     }
diff --git a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
index 6d52a93..109afd6 100644
--- a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.extensions.client;
 
+import com.google.common.base.MoreObjects;
+import java.util.Objects;
+
 public class DiffPreferencesInfo {
 
   /** Default number of lines of context. */
@@ -60,6 +63,97 @@
   public Boolean skipUnchanged;
   public Boolean skipUncommented;
 
+  @Override
+  public boolean equals(Object obj) {
+    if (!(obj instanceof DiffPreferencesInfo)) {
+      return false;
+    }
+    DiffPreferencesInfo other = (DiffPreferencesInfo) obj;
+    return Objects.equals(this.context, other.context)
+        && Objects.equals(this.tabSize, other.tabSize)
+        && Objects.equals(this.fontSize, other.fontSize)
+        && Objects.equals(this.lineLength, other.lineLength)
+        && Objects.equals(this.cursorBlinkRate, other.cursorBlinkRate)
+        && Objects.equals(this.expandAllComments, other.expandAllComments)
+        && Objects.equals(this.intralineDifference, other.intralineDifference)
+        && Objects.equals(this.manualReview, other.manualReview)
+        && Objects.equals(this.showLineEndings, other.showLineEndings)
+        && Objects.equals(this.showTabs, other.showTabs)
+        && Objects.equals(this.showWhitespaceErrors, other.showWhitespaceErrors)
+        && Objects.equals(this.syntaxHighlighting, other.syntaxHighlighting)
+        && Objects.equals(this.hideTopMenu, other.hideTopMenu)
+        && Objects.equals(this.autoHideDiffTableHeader, other.autoHideDiffTableHeader)
+        && Objects.equals(this.hideLineNumbers, other.hideLineNumbers)
+        && Objects.equals(this.renderEntireFile, other.renderEntireFile)
+        && Objects.equals(this.hideEmptyPane, other.hideEmptyPane)
+        && Objects.equals(this.matchBrackets, other.matchBrackets)
+        && Objects.equals(this.lineWrapping, other.lineWrapping)
+        && Objects.equals(this.ignoreWhitespace, other.ignoreWhitespace)
+        && Objects.equals(this.retainHeader, other.retainHeader)
+        && Objects.equals(this.skipDeleted, other.skipDeleted)
+        && Objects.equals(this.skipUnchanged, other.skipUnchanged)
+        && Objects.equals(this.skipUncommented, other.skipUncommented);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        context,
+        tabSize,
+        fontSize,
+        lineLength,
+        cursorBlinkRate,
+        expandAllComments,
+        intralineDifference,
+        manualReview,
+        showLineEndings,
+        showTabs,
+        showWhitespaceErrors,
+        syntaxHighlighting,
+        hideTopMenu,
+        autoHideDiffTableHeader,
+        hideLineNumbers,
+        renderEntireFile,
+        hideEmptyPane,
+        matchBrackets,
+        lineWrapping,
+        ignoreWhitespace,
+        retainHeader,
+        skipDeleted,
+        skipUnchanged,
+        skipUncommented);
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper("DiffPreferencesInfo")
+        .add("context", context)
+        .add("tabSize", tabSize)
+        .add("fontSize", fontSize)
+        .add("lineLength", lineLength)
+        .add("cursorBlinkRate", cursorBlinkRate)
+        .add("expandAllComments", expandAllComments)
+        .add("intralineDifference", intralineDifference)
+        .add("manualReview", manualReview)
+        .add("showLineEndings", showLineEndings)
+        .add("showTabs", showTabs)
+        .add("showWhitespaceErrors", showWhitespaceErrors)
+        .add("syntaxHighlighting", syntaxHighlighting)
+        .add("hideTopMenu", hideTopMenu)
+        .add("autoHideDiffTableHeader", autoHideDiffTableHeader)
+        .add("hideLineNumbers", hideLineNumbers)
+        .add("renderEntireFile", renderEntireFile)
+        .add("hideEmptyPane", hideEmptyPane)
+        .add("matchBrackets", matchBrackets)
+        .add("lineWrapping", lineWrapping)
+        .add("ignoreWhitespace", ignoreWhitespace)
+        .add("retainHeader", retainHeader)
+        .add("skipDeleted", skipDeleted)
+        .add("skipUnchanged", skipUnchanged)
+        .add("skipUncommented", skipUncommented)
+        .toString();
+  }
+
   public static DiffPreferencesInfo defaults() {
     DiffPreferencesInfo i = new DiffPreferencesInfo();
     i.context = DEFAULT_CONTEXT;
diff --git a/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java b/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
index 6672cb1..0a3ec0a 100644
--- a/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.extensions.client;
 
+import com.google.common.base.MoreObjects;
+import java.util.Objects;
+
 /* This class is stored in Git config file. */
 public class EditPreferencesInfo {
   public Integer tabSize;
@@ -31,6 +34,67 @@
   public Boolean autoCloseBrackets;
   public Boolean showBase;
 
+  @Override
+  public boolean equals(Object obj) {
+    if (!(obj instanceof EditPreferencesInfo)) {
+      return false;
+    }
+    EditPreferencesInfo other = (EditPreferencesInfo) obj;
+    return Objects.equals(this.tabSize, other.tabSize)
+        && Objects.equals(this.lineLength, other.lineLength)
+        && Objects.equals(this.indentUnit, other.indentUnit)
+        && Objects.equals(this.cursorBlinkRate, other.cursorBlinkRate)
+        && Objects.equals(this.hideTopMenu, other.hideTopMenu)
+        && Objects.equals(this.showTabs, other.showTabs)
+        && Objects.equals(this.showWhitespaceErrors, other.showWhitespaceErrors)
+        && Objects.equals(this.syntaxHighlighting, other.syntaxHighlighting)
+        && Objects.equals(this.hideLineNumbers, other.hideLineNumbers)
+        && Objects.equals(this.matchBrackets, other.matchBrackets)
+        && Objects.equals(this.lineWrapping, other.lineWrapping)
+        && Objects.equals(this.indentWithTabs, other.indentWithTabs)
+        && Objects.equals(this.autoCloseBrackets, other.autoCloseBrackets)
+        && Objects.equals(this.showBase, other.showBase);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        tabSize,
+        lineLength,
+        indentUnit,
+        cursorBlinkRate,
+        hideTopMenu,
+        showTabs,
+        showWhitespaceErrors,
+        syntaxHighlighting,
+        hideLineNumbers,
+        matchBrackets,
+        lineWrapping,
+        indentWithTabs,
+        autoCloseBrackets,
+        showBase);
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper("EditPreferencesInfo")
+        .add("tabSize", tabSize)
+        .add("lineLength", lineLength)
+        .add("indentUnit", indentUnit)
+        .add("cursorBlinkRate", cursorBlinkRate)
+        .add("hideTopMenu", hideTopMenu)
+        .add("showTabs", showTabs)
+        .add("showWhitespaceErrors", showWhitespaceErrors)
+        .add("syntaxHighlighting", syntaxHighlighting)
+        .add("hideLineNumbers", hideLineNumbers)
+        .add("matchBrackets", matchBrackets)
+        .add("lineWrapping", lineWrapping)
+        .add("indentWithTabs", indentWithTabs)
+        .add("autoCloseBrackets", autoCloseBrackets)
+        .add("showBase", showBase)
+        .toString();
+  }
+
   public static EditPreferencesInfo defaults() {
     EditPreferencesInfo i = new EditPreferencesInfo();
     i.tabSize = 8;
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index a374418..a9bde0c 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.extensions.client;
 
+import com.google.common.base.MoreObjects;
 import java.util.List;
+import java.util.Objects;
 
 /** Preferences about a single user. */
 public class GeneralPreferencesInfo {
@@ -22,16 +24,6 @@
   /** Default number of items to display per page. */
   public static final int DEFAULT_PAGESIZE = 25;
 
-  /** Preferred method to download a change. */
-  public enum DownloadCommand {
-    PULL,
-    CHECKOUT,
-    CHERRY_PICK,
-    FORMAT_PATCH,
-    BRANCH,
-    RESET,
-  }
-
   public enum DateFormat {
     /** US style dates: Apr 27, Feb 14, 2010 */
     STD("MMM d", "MMM d, yyyy"),
@@ -152,6 +144,14 @@
   public List<String> changeTable;
   public Boolean allowBrowserNotifications;
 
+  /**
+   * The sidebar section that the user prefers to have open on the diff page, or "NONE" if all
+   * sidebars should be closed.
+   *
+   * <p>Sidebars supplied by plugins are prefixed with "plugin-".
+   */
+  public String diffPageSidebar;
+
   public DateFormat getDateFormat() {
     if (dateFormat == null) {
       return DateFormat.STD;
@@ -187,6 +187,94 @@
     return emailFormat;
   }
 
+  @Override
+  public boolean equals(Object obj) {
+    if (!(obj instanceof GeneralPreferencesInfo)) {
+      return false;
+    }
+    GeneralPreferencesInfo other = (GeneralPreferencesInfo) obj;
+    return Objects.equals(this.changesPerPage, other.changesPerPage)
+        && Objects.equals(this.downloadScheme, other.downloadScheme)
+        && Objects.equals(this.theme, other.theme)
+        && Objects.equals(this.dateFormat, other.dateFormat)
+        && Objects.equals(this.timeFormat, other.timeFormat)
+        && Objects.equals(this.expandInlineDiffs, other.expandInlineDiffs)
+        && Objects.equals(this.relativeDateInChangeTable, other.relativeDateInChangeTable)
+        && Objects.equals(this.diffView, other.diffView)
+        && Objects.equals(this.sizeBarInChangeTable, other.sizeBarInChangeTable)
+        && Objects.equals(this.legacycidInChangeTable, other.legacycidInChangeTable)
+        && Objects.equals(this.muteCommonPathPrefixes, other.muteCommonPathPrefixes)
+        && Objects.equals(this.signedOffBy, other.signedOffBy)
+        && Objects.equals(this.emailStrategy, other.emailStrategy)
+        && Objects.equals(this.emailFormat, other.emailFormat)
+        && Objects.equals(this.defaultBaseForMerges, other.defaultBaseForMerges)
+        && Objects.equals(this.publishCommentsOnPush, other.publishCommentsOnPush)
+        && Objects.equals(this.disableKeyboardShortcuts, other.disableKeyboardShortcuts)
+        && Objects.equals(this.disableTokenHighlighting, other.disableTokenHighlighting)
+        && Objects.equals(this.workInProgressByDefault, other.workInProgressByDefault)
+        && Objects.equals(this.my, other.my)
+        && Objects.equals(this.changeTable, other.changeTable)
+        && Objects.equals(this.allowBrowserNotifications, other.allowBrowserNotifications)
+        && Objects.equals(this.diffPageSidebar, other.diffPageSidebar);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        changesPerPage,
+        downloadScheme,
+        theme,
+        dateFormat,
+        timeFormat,
+        expandInlineDiffs,
+        relativeDateInChangeTable,
+        diffView,
+        sizeBarInChangeTable,
+        legacycidInChangeTable,
+        muteCommonPathPrefixes,
+        signedOffBy,
+        emailStrategy,
+        emailFormat,
+        defaultBaseForMerges,
+        publishCommentsOnPush,
+        disableKeyboardShortcuts,
+        disableTokenHighlighting,
+        workInProgressByDefault,
+        my,
+        changeTable,
+        allowBrowserNotifications,
+        diffPageSidebar);
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper("GeneralPreferencesInfo")
+        .add("changesPerPage", changesPerPage)
+        .add("downloadScheme", downloadScheme)
+        .add("theme", theme)
+        .add("dateFormat", dateFormat)
+        .add("timeFormat", timeFormat)
+        .add("expandInlineDiffs", expandInlineDiffs)
+        .add("relativeDateInChangeTable", relativeDateInChangeTable)
+        .add("diffView", diffView)
+        .add("sizeBarInChangeTable", sizeBarInChangeTable)
+        .add("legacycidInChangeTable", legacycidInChangeTable)
+        .add("muteCommonPathPrefixes", muteCommonPathPrefixes)
+        .add("signedOffBy", signedOffBy)
+        .add("emailStrategy", emailStrategy)
+        .add("emailFormat", emailFormat)
+        .add("defaultBaseForMerges", defaultBaseForMerges)
+        .add("publishCommentsOnPush", publishCommentsOnPush)
+        .add("disableKeyboardShortcuts", disableKeyboardShortcuts)
+        .add("disableTokenHighlighting", disableTokenHighlighting)
+        .add("workInProgressByDefault", workInProgressByDefault)
+        .add("my", my)
+        .add("changeTable", changeTable)
+        .add("allowBrowserNotifications", allowBrowserNotifications)
+        .add("diffPageSidebar", diffPageSidebar)
+        .toString();
+  }
+
   public static GeneralPreferencesInfo defaults() {
     GeneralPreferencesInfo p = new GeneralPreferencesInfo();
     p.changesPerPage = DEFAULT_PAGESIZE;
@@ -209,6 +297,7 @@
     p.disableTokenHighlighting = false;
     p.workInProgressByDefault = false;
     p.allowBrowserNotifications = true;
+    p.diffPageSidebar = "NONE";
     return p;
   }
 }
diff --git a/java/com/google/gerrit/extensions/client/ListChangesOption.java b/java/com/google/gerrit/extensions/client/ListChangesOption.java
index 48a3502..4cf7b0a 100644
--- a/java/com/google/gerrit/extensions/client/ListChangesOption.java
+++ b/java/com/google/gerrit/extensions/client/ListChangesOption.java
@@ -90,8 +90,17 @@
   /** Include the evaluated submit requirements for the caller. */
   SUBMIT_REQUIREMENTS(24),
 
+  /** Include custom keyed values. */
+  CUSTOM_KEYED_VALUES(25),
+
   /** Include the 'starred' field, that is if the change is starred by the current user . */
-  STAR(25);
+  STAR(26),
+
+  /**
+   * Include the `parents_data` field in each revision, e.g. if it's merged in the target branch and
+   * whether it points to a patch-set of another change.
+   */
+  PARENTS(27);
 
   private final int value;
 
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 3a1522e..d23a22c 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -37,6 +37,8 @@
   // protected by any ListChangesOption.
 
   public String id;
+  public String tripletId;
+
   public String project;
   public String branch;
   public String topic;
@@ -50,6 +52,8 @@
 
   public Map<Integer, AttentionSetInfo> removedFromAttentionSet;
 
+  public Map<String, String> customKeyedValues;
+
   public Collection<String> hashtags;
   public String changeId;
   public String subject;
diff --git a/java/com/google/gerrit/extensions/common/ChangeInput.java b/java/com/google/gerrit/extensions/common/ChangeInput.java
index 6f9cff7..2e2b9ca 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInput.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInput.java
@@ -38,6 +38,7 @@
   public String baseCommit;
   public Boolean newBranch;
   public Map<String, String> validationOptions;
+  public Map<String, String> customKeyedValues;
   public MergeInput merge;
   public ApplyPatchInput patch;
 
diff --git a/java/com/google/gerrit/extensions/common/EmailInfo.java b/java/com/google/gerrit/extensions/common/EmailInfo.java
index 184a89f..96e8adf 100644
--- a/java/com/google/gerrit/extensions/common/EmailInfo.java
+++ b/java/com/google/gerrit/extensions/common/EmailInfo.java
@@ -20,6 +20,6 @@
   public Boolean pendingConfirmation;
 
   public void preferred(String e) {
-    this.preferred = e != null && e.equals(email) ? true : null;
+    this.preferred = (e != null && e.equals(email)) ? true : null;
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/GerritInfo.java b/java/com/google/gerrit/extensions/common/GerritInfo.java
index 3265a00..547e606 100644
--- a/java/com/google/gerrit/extensions/common/GerritInfo.java
+++ b/java/com/google/gerrit/extensions/common/GerritInfo.java
@@ -24,4 +24,5 @@
   public String reportBugUrl;
   public String primaryWeblinkName;
   public String instanceId;
+  public String defaultBranch;
 }
diff --git a/java/com/google/gerrit/extensions/common/MergePatchSetInput.java b/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
index 734d7e9..4a769dd 100644
--- a/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
+++ b/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.common;
 
 import com.google.gerrit.extensions.api.accounts.AccountInput;
+import java.util.Map;
 
 public class MergePatchSetInput {
   public String subject;
@@ -22,4 +23,5 @@
   public String baseChange;
   public MergeInput merge;
   public AccountInput author;
+  public Map<String, String> validationOptions;
 }
diff --git a/java/com/google/gerrit/extensions/common/ProjectInfo.java b/java/com/google/gerrit/extensions/common/ProjectInfo.java
index 46b2599..2b00710 100644
--- a/java/com/google/gerrit/extensions/common/ProjectInfo.java
+++ b/java/com/google/gerrit/extensions/common/ProjectInfo.java
@@ -27,4 +27,10 @@
   public Map<String, String> branches;
   public List<WebLinkInfo> webLinks;
   public Map<String, LabelTypeInfo> labels;
+
+  /**
+   * Whether the query would deliver more results if not limited. Only set on the last project that
+   * is returned as a query result.
+   */
+  public Boolean _moreProjects;
 }
diff --git a/java/com/google/gerrit/extensions/common/RevisionInfo.java b/java/com/google/gerrit/extensions/common/RevisionInfo.java
index 941dffe..7b74a06 100644
--- a/java/com/google/gerrit/extensions/common/RevisionInfo.java
+++ b/java/com/google/gerrit/extensions/common/RevisionInfo.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.extensions.client.ChangeKind;
 import java.sql.Timestamp;
 import java.time.Instant;
+import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 
@@ -36,6 +37,8 @@
   public String ref;
   public Map<String, FetchInfo> fetch;
   public CommitInfo commit;
+  public List<ParentInfo> parentsData;
+  public String branch;
   public Map<String, FileInfo> files;
   public Map<String, ActionInfo> actions;
   public String commitWithFooters;
@@ -77,6 +80,8 @@
           && Objects.equals(ref, revisionInfo.ref)
           && Objects.equals(fetch, revisionInfo.fetch)
           && Objects.equals(commit, revisionInfo.commit)
+          && Objects.equals(parentsData, revisionInfo.parentsData)
+          && Objects.equals(branch, revisionInfo.branch)
           && Objects.equals(files, revisionInfo.files)
           && Objects.equals(actions, revisionInfo.actions)
           && Objects.equals(commitWithFooters, revisionInfo.commitWithFooters)
@@ -98,10 +103,74 @@
         ref,
         fetch,
         commit,
+        parentsData,
+        branch,
         files,
         actions,
         commitWithFooters,
         pushCertificate,
         description);
   }
+
+  public static class ParentInfo {
+    /** The name of the target branch where the patch-set commit is set to be merged into. */
+    public String branchName;
+
+    /** The commit SHA-1 of the parent commit. */
+    public String commitId;
+
+    /** Whether the parent commit is merged in the target branch. */
+    public Boolean isMergedInTargetBranch;
+
+    /**
+     * If the parent commit is a patch-set of another gerrit change, this field will hold the change
+     * ID of the parent change. Otherwise, will be null.
+     */
+    public String changeId;
+
+    /**
+     * If the parent commit is a patch-set of another gerrit change, this field will hold the change
+     * number of the parent change. Otherwise, will be null.
+     */
+    public Integer changeNumber;
+
+    /**
+     * If the parent commit is a patch-set of another gerrit change, this field will hold the
+     * patch-set number of the parent change. Otherwise, will be null.
+     */
+    public Integer patchSetNumber;
+
+    /**
+     * If the parent commit is a patch-set of another gerrit change, this field will hold the change
+     * status of the parent change. Otherwise, will be null.
+     */
+    public String changeStatus;
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof ParentInfo) {
+        ParentInfo parentInfo = (ParentInfo) o;
+        return Objects.equals(branchName, parentInfo.branchName)
+            && Objects.equals(commitId, parentInfo.commitId)
+            && Objects.equals(isMergedInTargetBranch, parentInfo.isMergedInTargetBranch)
+            && Objects.equals(changeId, parentInfo.changeId)
+            && Objects.equals(changeNumber, parentInfo.changeNumber)
+            && Objects.equals(patchSetNumber, parentInfo.patchSetNumber)
+            && Objects.equals(changeStatus, parentInfo.changeStatus);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(
+          branchName,
+          commitId,
+          isMergedInTargetBranch,
+          changeId,
+          changeNumber,
+          patchSetNumber,
+          changeStatus);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/VersionInfo.java b/java/com/google/gerrit/extensions/common/VersionInfo.java
new file mode 100644
index 0000000..f18e1cc
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/VersionInfo.java
@@ -0,0 +1,41 @@
+// 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.extensions.common;
+
+public class VersionInfo {
+  public String gerritVersion;
+  public int noteDbVersion;
+  public int changeIndexVersion;
+  public int accountIndexVersion;
+  public int projectIndexVersion;
+  public int groupIndexVersion;
+
+  public String compact() {
+    return "gerrit version " + gerritVersion + "\n";
+  }
+
+  public String verbose() {
+    StringBuilder s = new StringBuilder();
+    s.append("gerrit version " + gerritVersion).append("\n");
+    s.append("NoteDb version " + noteDbVersion).append("\n");
+    s.append("Index versions\n");
+    String format = "  %-8s %3d\n";
+    s.append(String.format(format, "changes", changeIndexVersion));
+    s.append(String.format(format, "accounts", accountIndexVersion));
+    s.append(String.format(format, "projects", projectIndexVersion));
+    s.append(String.format(format, "groups", groupIndexVersion));
+    return s.toString();
+  }
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/BUILD b/java/com/google/gerrit/extensions/common/testing/BUILD
index c976de0..33cbb99 100644
--- a/java/com/google/gerrit/extensions/common/testing/BUILD
+++ b/java/com/google/gerrit/extensions/common/testing/BUILD
@@ -6,10 +6,13 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/truth",
         "//lib:guava",
         "//lib:jgit",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
         "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/extensions/common/testing/ChangeInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/ChangeInfoSubject.java
new file mode 100644
index 0000000..f9850dc
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/testing/ChangeInfoSubject.java
@@ -0,0 +1,112 @@
+// 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.extensions.common.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.Subject;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.common.ApprovalInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.LabelInfo;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+
+/** A Truth subject for {@link ChangeInfo} instances. */
+public class ChangeInfoSubject extends Subject {
+  private final ChangeInfo changeInfo;
+
+  public static ChangeInfoSubject assertThat(ChangeInfo changeInfo) {
+    return assertAbout(changes()).that(changeInfo);
+  }
+
+  public static Factory<ChangeInfoSubject, ChangeInfo> changes() {
+    return ChangeInfoSubject::new;
+  }
+
+  private ChangeInfoSubject(FailureMetadata metadata, ChangeInfo changeInfo) {
+    super(metadata, changeInfo);
+    this.changeInfo = changeInfo;
+  }
+
+  private ChangeInfo changeInfo() {
+    isNotNull();
+    return changeInfo;
+  }
+
+  /**
+   * Asserts that the ChangeInfo has exactly the provided votes or fails.
+   *
+   * <p>The 0-value votes and non-existing votes are treated as equal votes. In other word, if
+   * expectedVote has value zero, then the actual vote can be either 0 or not present at all and
+   * vice-verse.
+   */
+  public void hasExactlyVotes(Vote... expectedVotes) {
+    assertWithMessage("ChangeInfo.labels is null").that(changeInfo().labels).isNotNull();
+    Set<Vote> actualVotes = getAllNonZeroVotes(changeInfo().labels);
+    Arrays.stream(expectedVotes)
+        .filter(v -> v.value() == 0 && !actualVotes.contains(v))
+        .forEach(actualVotes::add);
+    assertWithMessage("Votes are different.")
+        .that(actualVotes)
+        .containsExactlyElementsIn(expectedVotes);
+  }
+
+  /** Assers that the ChangeInfo has no votes or fails. */
+  public void hasNoVotes() {
+    hasExactlyVotes();
+  }
+
+  private static Set<Vote> getAllNonZeroVotes(Map<String, LabelInfo> labels) {
+    Set<Vote> votes = new HashSet<>();
+    for (Entry<String, LabelInfo> entry : labels.entrySet()) {
+      List<ApprovalInfo> allApprovals = entry.getValue().all;
+      if (allApprovals == null) {
+        continue;
+      }
+      allApprovals.stream()
+          .filter(approvalInfo -> !approvalInfo.value.equals(0))
+          .map(
+              apprvoalInfo ->
+                  vote(entry.getKey(), Account.id(apprvoalInfo._accountId), apprvoalInfo.value))
+          .forEach(votes::add);
+    }
+    return votes;
+  }
+
+  public static Vote vote(String labelId, Account.Id accountId, int value) {
+    return Vote.create(labelId, accountId, value);
+  }
+
+  @AutoValue
+  public abstract static class Vote {
+    static Vote create(String labelId, Account.Id accountId, int value) {
+      return new AutoValue_ChangeInfoSubject_Vote(labelId, accountId, value);
+    }
+
+    public abstract String labelId();
+
+    public abstract Account.Id accountId();
+
+    public abstract int value();
+  }
+}
diff --git a/java/com/google/gerrit/extensions/events/CommentAddedListener.java b/java/com/google/gerrit/extensions/events/CommentAddedListener.java
index 071dac1..7e0b623 100644
--- a/java/com/google/gerrit/extensions/events/CommentAddedListener.java
+++ b/java/com/google/gerrit/extensions/events/CommentAddedListener.java
@@ -14,6 +14,7 @@
 
 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.ApprovalInfo;
 import java.util.Map;
@@ -22,6 +23,7 @@
 @ExtensionPoint
 public interface CommentAddedListener {
   interface Event extends RevisionEvent {
+    @Nullable
     String getComment();
 
     Map<String, ApprovalInfo> getApprovals();
diff --git a/java/com/google/gerrit/extensions/events/CustomKeyedValuesEditedListener.java b/java/com/google/gerrit/extensions/events/CustomKeyedValuesEditedListener.java
new file mode 100644
index 0000000..d008675
--- /dev/null
+++ b/java/com/google/gerrit/extensions/events/CustomKeyedValuesEditedListener.java
@@ -0,0 +1,33 @@
+// 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.extensions.events;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/** Notified whenever a Change's Custom Keyed Values are edited. */
+@ExtensionPoint
+public interface CustomKeyedValuesEditedListener {
+  interface Event extends ChangeEvent {
+    ImmutableMap<String, String> getCustomKeyedValues();
+
+    ImmutableMap<String, String> getAddedCustomKeyedValues();
+
+    ImmutableSet<String> getRemovedCustomKeys();
+  }
+
+  void onCustomKeyedValuesEdited(Event event);
+}
diff --git a/java/com/google/gerrit/extensions/restapi/Response.java b/java/com/google/gerrit/extensions/restapi/Response.java
index bf363d8..3ebae8d 100644
--- a/java/com/google/gerrit/extensions/restapi/Response.java
+++ b/java/com/google/gerrit/extensions/restapi/Response.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.restapi;
 
+import com.google.common.collect.ImmutableMultimap;
 import java.util.concurrent.TimeUnit;
 
 /** Special return value to mean specific HTTP status codes in a REST API. */
@@ -23,7 +24,11 @@
 
   /** HTTP 200 OK: pointless wrapper for type safety. */
   public static <T> Response<T> ok(T value) {
-    return new Impl<>(200, value);
+    return ok(value, ImmutableMultimap.of());
+  }
+
+  public static <T> Response<T> ok(T value, ImmutableMultimap<String, String> headers) {
+    return new Impl<>(200, value, headers);
   }
 
   /** HTTP 200 OK: with empty value. */
@@ -81,6 +86,8 @@
 
   public abstract T value();
 
+  public abstract ImmutableMultimap<String, String> headers();
+
   public abstract CacheControl caching();
 
   public abstract Response<T> caching(CacheControl c);
@@ -91,11 +98,17 @@
   private static final class Impl<T> extends Response<T> {
     private final int statusCode;
     private final T value;
+    private final ImmutableMultimap<String, String> headers;
     private CacheControl caching = CacheControl.NONE;
 
     private Impl(int sc, T val) {
+      this(sc, val, ImmutableMultimap.of());
+    }
+
+    private Impl(int sc, T val, ImmutableMultimap<String, String> hs) {
       statusCode = sc;
       value = val;
+      headers = hs;
     }
 
     @Override
@@ -114,6 +127,11 @@
     }
 
     @Override
+    public ImmutableMultimap<String, String> headers() {
+      return headers;
+    }
+
+    @Override
     public CacheControl caching() {
       return caching;
     }
@@ -149,6 +167,11 @@
     }
 
     @Override
+    public ImmutableMultimap<String, String> headers() {
+      return ImmutableMultimap.of();
+    }
+
+    @Override
     public CacheControl caching() {
       return CacheControl.NONE;
     }
@@ -188,6 +211,11 @@
     }
 
     @Override
+    public ImmutableMultimap<String, String> headers() {
+      return ImmutableMultimap.of();
+    }
+
+    @Override
     public CacheControl caching() {
       return CacheControl.NONE;
     }
@@ -241,6 +269,11 @@
     }
 
     @Override
+    public ImmutableMultimap<String, String> headers() {
+      return ImmutableMultimap.of();
+    }
+
+    @Override
     public CacheControl caching() {
       return CacheControl.NONE;
     }
diff --git a/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java b/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
index 74bccbd..4f8fa10 100644
--- a/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
+++ b/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
@@ -59,6 +59,7 @@
    * @return WebLinkInfo that links to patch set in external service, null if there should be no
    *     link.
    */
+  @Deprecated
   default WebLinkInfo getPatchSetWebLink(
       String projectName,
       String commit,
@@ -67,4 +68,33 @@
       String changeKey) {
     return getPatchSetWebLink(projectName, commit, commitMessage, 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
+   * @param numericChangeId the numeric 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,
+      int numericChangeId) {
+    return getPatchSetWebLink(projectName, commit, commitMessage, branchName, changeKey);
+  }
 }
diff --git a/java/com/google/gerrit/git/BUILD b/java/com/google/gerrit/git/BUILD
index 0574716..98dacfa 100644
--- a/java/com/google/gerrit/git/BUILD
+++ b/java/com/google/gerrit/git/BUILD
@@ -10,5 +10,6 @@
         "//lib:jgit",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
     ],
 )
diff --git a/java/com/google/gerrit/git/RefUpdateUtil.java b/java/com/google/gerrit/git/RefUpdateUtil.java
index 13b0451..43ce3f9 100644
--- a/java/com/google/gerrit/git/RefUpdateUtil.java
+++ b/java/com/google/gerrit/git/RefUpdateUtil.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.git;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import java.io.IOException;
 import org.eclipse.jgit.internal.JGitText;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -147,18 +148,19 @@
    *     occurs.
    * @throws IOException if an error occurred.
    */
-  public static void deleteChecked(Repository repo, String refName) throws IOException {
+  @CanIgnoreReturnValue
+  public static RefUpdate deleteChecked(Repository repo, String refName) throws IOException {
     RefUpdate ru = repo.updateRef(refName);
     ru.setForceUpdate(true);
     ru.setCheckConflicting(false);
     switch (ru.delete()) {
       case FORCED:
         // Ref was deleted.
-        return;
+        return ru;
 
       case NEW:
         // Ref didn't exist (yes, really).
-        return;
+        return ru;
 
       case LOCK_FAILURE:
         throw new LockFailureException("Failed to delete " + refName + ": " + ru.getResult(), ru);
diff --git a/java/com/google/gerrit/gpg/BUILD b/java/com/google/gerrit/gpg/BUILD
index b2173c4..3958821 100644
--- a/java/com/google/gerrit/gpg/BUILD
+++ b/java/com/google/gerrit/gpg/BUILD
@@ -4,6 +4,9 @@
     name = "gpg",
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
+    runtime_deps = [
+        "//lib/bouncycastle:bcpg",
+    ],
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
@@ -11,7 +14,6 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/api",
         "//lib:guava",
         "//lib:jgit",
         "//lib/auto:auto-factory",
diff --git a/java/com/google/gerrit/gpg/PublicKeyChecker.java b/java/com/google/gerrit/gpg/PublicKeyChecker.java
index 5347398..946fee3 100644
--- a/java/com/google/gerrit/gpg/PublicKeyChecker.java
+++ b/java/com/google/gerrit/gpg/PublicKeyChecker.java
@@ -237,7 +237,6 @@
       List<PGPSignature> revocations,
       Map<Long, RevocationKey> revokers)
       throws PGPException {
-    @SuppressWarnings("unchecked")
     Iterator<PGPSignature> allSigs = key.getSignatures();
     while (allSigs.hasNext()) {
       PGPSignature sig = allSigs.next();
diff --git a/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java b/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java
new file mode 100644
index 0000000..7040f2d
--- /dev/null
+++ b/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java
@@ -0,0 +1,123 @@
+// 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.gpg;
+
+import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.io.BaseEncoding;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.bouncycastle.openpgp.PGPException;
+import org.bouncycastle.openpgp.PGPPublicKey;
+import org.bouncycastle.openpgp.PGPPublicKeyRing;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.util.NB;
+
+public class PublicKeyStoreUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final ExternalIds externalIds;
+  private final Provider<PublicKeyStore> storeProvider;
+
+  @Inject
+  PublicKeyStoreUtil(ExternalIds externalIds, Provider<PublicKeyStore> storeProvider) {
+    this.externalIds = externalIds;
+    this.storeProvider = storeProvider;
+  }
+
+  public static byte[] parseFingerprint(ExternalId gpgKeyExtId) {
+    return BaseEncoding.base16().decode(gpgKeyExtId.key().id());
+  }
+
+  public static long keyIdFromFingerprint(byte[] fp) {
+    return NB.decodeInt64(fp, fp.length - 8);
+  }
+
+  public boolean hasInitializedPublicKeyStore() {
+    try {
+      return storeProvider.get() != null;
+    } catch (Exception e) {
+      return false;
+    }
+  }
+
+  public List<PGPPublicKey> listGpgKeysForUser(Account.Id id) throws PGPException, IOException {
+    List<PGPPublicKey> keys = new ArrayList<>();
+    try (PublicKeyStore store = storeProvider.get()) {
+      for (ExternalId extId : getGpgExtIds(id)) {
+        byte[] fp = parseFingerprint(extId);
+        boolean found = false;
+        for (PGPPublicKeyRing keyRing : store.get(keyIdFromFingerprint(fp))) {
+          if (Arrays.equals(keyRing.getPublicKey().getFingerprint(), fp)) {
+            found = true;
+            keys.add(keyRing.getPublicKey());
+            break;
+          }
+        }
+        if (!found) {
+          logger.atWarning().log(
+              "No public key stored for fingerprint %s", Fingerprint.toString(fp));
+        }
+      }
+    }
+    return keys;
+  }
+
+  public Iterable<ExternalId> getGpgExtIds(Account.Id id) throws IOException {
+    return externalIds.byAccount(id, SCHEME_GPGKEY);
+  }
+
+  public RefUpdate.Result deletePgpKey(PGPPublicKey key, PersonIdent committer, PersonIdent author)
+      throws PGPException, IOException {
+    return deletePgpKeys(ImmutableList.of(key), committer, author).get(0);
+  }
+
+  public List<RefUpdate.Result> deletePgpKeys(
+      List<PGPPublicKey> keys, PersonIdent committer, PersonIdent author)
+      throws IOException, PGPException {
+    List<RefUpdate.Result> res = new ArrayList<>();
+    try (PublicKeyStore store = storeProvider.get()) {
+      for (PGPPublicKey key : keys) {
+        store.remove(key.getFingerprint());
+
+        CommitBuilder cb = new CommitBuilder();
+        cb.setAuthor(author);
+        cb.setCommitter(committer);
+        cb.setMessage("Delete public key " + keyIdToString(key.getKeyID()));
+
+        RefUpdate.Result saveResult = store.save(cb);
+        res.add(saveResult);
+      }
+    }
+    return res;
+  }
+
+  public List<RefUpdate.Result> deleteAllPgpKeysForUser(
+      Account.Id id, PersonIdent committer, PersonIdent author) throws PGPException, IOException {
+    return deletePgpKeys(listGpgKeysForUser(id), committer, author);
+  }
+}
diff --git a/java/com/google/gerrit/gpg/SignedPushModule.java b/java/com/google/gerrit/gpg/SignedPushModule.java
index f4fb9f9..98487ca 100644
--- a/java/com/google/gerrit/gpg/SignedPushModule.java
+++ b/java/com/google/gerrit/gpg/SignedPushModule.java
@@ -33,6 +33,7 @@
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
+import com.google.inject.multibindings.OptionalBinder;
 import java.io.IOException;
 import java.security.NoSuchAlgorithmException;
 import java.security.SecureRandom;
@@ -54,7 +55,12 @@
     if (!BouncyCastleUtil.havePGP()) {
       throw new ProvisionException("Bouncy Castle PGP not installed");
     }
-    bind(PublicKeyStore.class).toProvider(StoreProvider.class);
+    // This binding is optional as some modules might bind
+    // {@code UnimplementedPublicKeyStoreProvider} as default binding.
+    OptionalBinder.newOptionalBinder(binder(), PublicKeyStore.class)
+        .setBinding()
+        .toProvider(StoreProvider.class);
+
     DynamicSet.bind(binder(), ReceivePackInitializer.class).to(Initializer.class);
   }
 
diff --git a/java/com/google/gerrit/gpg/UnimplementedPublicKeyStoreProvider.java b/java/com/google/gerrit/gpg/UnimplementedPublicKeyStoreProvider.java
new file mode 100644
index 0000000..12e8edb
--- /dev/null
+++ b/java/com/google/gerrit/gpg/UnimplementedPublicKeyStoreProvider.java
@@ -0,0 +1,27 @@
+// 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.gpg;
+
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class UnimplementedPublicKeyStoreProvider implements Provider<PublicKeyStore> {
+  @Override
+  public PublicKeyStore get() {
+    throw new NotImplementedException("UnimplementedPublicKeyStoreProvider was bound.");
+  }
+}
diff --git a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
index 6ae0334..57fda5b 100644
--- a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
+++ b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.gpg.api;
 
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
 import com.google.gerrit.extensions.api.accounts.GpgKeysInput;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
@@ -70,8 +68,10 @@
       return gpgKeys.get().list().apply(account).value();
     } catch (PGPException | IOException e) {
       throw new GpgException(e);
+    } catch (RestApiException e) {
+      throw e;
     } catch (Exception e) {
-      throw asRestApiException("Cannot list GPG keys", e);
+      throw RestApiException.wrap("Cannot list GPG keys", e);
     }
   }
 
@@ -86,8 +86,10 @@
       return postGpgKeys.get().apply(account, in).value();
     } catch (PGPException | IOException | ConfigInvalidException e) {
       throw new GpgException(e);
+    } catch (RestApiException e) {
+      throw e;
     } catch (Exception e) {
-      throw asRestApiException("Cannot put GPG keys", e);
+      throw RestApiException.wrap("Cannot put GPG keys", e);
     }
   }
 
diff --git a/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java b/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
index 0ff12e8..2a05f35 100644
--- a/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
+++ b/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.gpg.api;
 
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.Input;
@@ -50,7 +48,7 @@
     try {
       return get.apply(rsrc).value();
     } catch (Exception e) {
-      throw asRestApiException("Cannot get GPG key", e);
+      throw RestApiException.wrap("Cannot get GPG key", e);
     }
   }
 
@@ -58,8 +56,10 @@
   public void delete() throws RestApiException {
     try {
       delete.apply(rsrc, new Input());
+    } catch (RestApiException e) {
+      throw e;
     } catch (PGPException | IOException | ConfigInvalidException e) {
-      throw asRestApiException("Cannot delete GPG key", e);
+      throw RestApiException.wrap("Cannot delete GPG key", e);
     }
   }
 }
diff --git a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
index bcc8631..7f057e8 100644
--- a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
+++ b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.gpg.server;
 
-import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.mail.EmailFactories.KEY_DELETED;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
@@ -28,13 +28,14 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.gpg.PublicKeyStore;
+import com.google.gerrit.gpg.PublicKeyStoreUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.mail.send.DeleteKeySender;
+import com.google.gerrit.server.mail.EmailFactories;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -42,7 +43,6 @@
 import org.bouncycastle.openpgp.PGPException;
 import org.bouncycastle.openpgp.PGPPublicKey;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
 
@@ -50,25 +50,25 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final Provider<PersonIdent> serverIdent;
-  private final Provider<PublicKeyStore> storeProvider;
+  private final PublicKeyStoreUtil publicKeyStoreUtil;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
   private final ExternalIds externalIds;
-  private final DeleteKeySender.Factory deleteKeySenderFactory;
+  private final EmailFactories emailFactories;
   private final ExternalIdKeyFactory externalIdKeyFactory;
 
   @Inject
   DeleteGpgKey(
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
-      Provider<PublicKeyStore> storeProvider,
+      PublicKeyStoreUtil publicKeyStoreUtil,
       @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
       ExternalIds externalIds,
-      DeleteKeySender.Factory deleteKeySenderFactory,
+      EmailFactories emailFactories,
       ExternalIdKeyFactory externalIdKeyFactory) {
     this.serverIdent = serverIdent;
-    this.storeProvider = storeProvider;
+    this.publicKeyStoreUtil = publicKeyStoreUtil;
     this.accountsUpdateProvider = accountsUpdateProvider;
     this.externalIds = externalIds;
-    this.deleteKeySenderFactory = deleteKeySenderFactory;
+    this.emailFactories = emailFactories;
     this.externalIdKeyFactory = externalIdKeyFactory;
   }
 
@@ -90,42 +90,38 @@
             rsrc.getUser().getAccountId(),
             u -> u.deleteExternalId(extId.get()));
 
-    try (PublicKeyStore store = storeProvider.get()) {
-      store.remove(rsrc.getKeyRing().getPublicKey().getFingerprint());
+    PersonIdent committer = serverIdent.get();
+    PersonIdent author = rsrc.getUser().newCommitterIdent(committer);
 
-      CommitBuilder cb = new CommitBuilder();
-      PersonIdent committer = serverIdent.get();
-      cb.setAuthor(rsrc.getUser().newCommitterIdent(committer));
-      cb.setCommitter(committer);
-      cb.setMessage("Delete public key " + keyIdToString(key.getKeyID()));
-
-      RefUpdate.Result saveResult = store.save(cb);
-      switch (saveResult) {
-        case NO_CHANGE:
-        case FAST_FORWARD:
-          try {
-            deleteKeySenderFactory
-                .create(rsrc.getUser(), ImmutableList.of(PublicKeyStore.keyToString(key)))
-                .send();
-          } catch (EmailException e) {
-            logger.atSevere().withCause(e).log(
-                "Cannot send GPG key deletion message to %s",
-                rsrc.getUser().getAccount().preferredEmail());
-          }
-          break;
-        case LOCK_FAILURE:
-        case FORCED:
-        case IO_FAILURE:
-        case NEW:
-        case NOT_ATTEMPTED:
-        case REJECTED:
-        case REJECTED_CURRENT_BRANCH:
-        case RENAMED:
-        case REJECTED_MISSING_OBJECT:
-        case REJECTED_OTHER_REASON:
-        default:
-          throw new StorageException(String.format("Failed to delete public key: %s", saveResult));
-      }
+    RefUpdate.Result saveResult = publicKeyStoreUtil.deletePgpKey(key, committer, author);
+    switch (saveResult) {
+      case NO_CHANGE:
+      case FAST_FORWARD:
+        try {
+          emailFactories
+              .createOutgoingEmail(
+                  KEY_DELETED,
+                  emailFactories.createDeleteKeyEmail(
+                      rsrc.getUser(), ImmutableList.of(PublicKeyStore.keyToString(key))))
+              .send();
+        } catch (EmailException e) {
+          logger.atSevere().withCause(e).log(
+              "Cannot send GPG key deletion message to %s",
+              rsrc.getUser().getAccount().preferredEmail());
+        }
+        break;
+      case LOCK_FAILURE:
+      case FORCED:
+      case IO_FAILURE:
+      case NEW:
+      case NOT_ATTEMPTED:
+      case REJECTED:
+      case REJECTED_CURRENT_BRANCH:
+      case RENAMED:
+      case REJECTED_MISSING_OBJECT:
+      case REJECTED_OTHER_REASON:
+      default:
+        throw new StorageException(String.format("Failed to delete public key: %s", saveResult));
     }
     return Response.none();
   }
diff --git a/java/com/google/gerrit/gpg/server/GpgKeys.java b/java/com/google/gerrit/gpg/server/GpgKeys.java
index 00a0f57..9fb8286 100644
--- a/java/com/google/gerrit/gpg/server/GpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/GpgKeys.java
@@ -14,13 +14,10 @@
 
 package com.google.gerrit.gpg.server;
 
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableList;
-import com.google.common.flogger.FluentLogger;
-import com.google.common.io.BaseEncoding;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -36,10 +33,10 @@
 import com.google.gerrit.gpg.GerritPublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyStore;
+import com.google.gerrit.gpg.PublicKeyStoreUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -48,36 +45,35 @@
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.Iterator;
+import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import org.bouncycastle.bcpg.ArmoredOutputStream;
 import org.bouncycastle.openpgp.PGPException;
 import org.bouncycastle.openpgp.PGPPublicKey;
 import org.bouncycastle.openpgp.PGPPublicKeyRing;
-import org.eclipse.jgit.util.NB;
 
 @Singleton
 public class GpgKeys implements ChildCollection<AccountResource, GpgKey> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final DynamicMap<RestView<GpgKey>> views;
   private final Provider<CurrentUser> self;
+  private final PublicKeyStoreUtil publicKeyStoreUtil;
   private final Provider<PublicKeyStore> storeProvider;
   private final GerritPublicKeyChecker.Factory checkerFactory;
-  private final ExternalIds externalIds;
 
   @Inject
   GpgKeys(
       DynamicMap<RestView<GpgKey>> views,
       Provider<CurrentUser> self,
+      PublicKeyStoreUtil publicKeyStoreUtil,
       Provider<PublicKeyStore> storeProvider,
-      GerritPublicKeyChecker.Factory checkerFactory,
-      ExternalIds externalIds) {
+      GerritPublicKeyChecker.Factory checkerFactory) {
     this.views = views;
     this.self = self;
+    this.publicKeyStoreUtil = publicKeyStoreUtil;
     this.storeProvider = storeProvider;
     this.checkerFactory = checkerFactory;
-    this.externalIds = externalIds;
   }
 
   @Override
@@ -90,10 +86,11 @@
       throws ResourceNotFoundException, PGPException, IOException {
     checkVisible(self, parent);
 
-    ExternalId gpgKeyExtId = findGpgKey(id.get(), getGpgExtIds(parent));
-    byte[] fp = parseFingerprint(gpgKeyExtId);
+    ExternalId gpgKeyExtId =
+        findGpgKey(id.get(), publicKeyStoreUtil.getGpgExtIds(parent.getUser().getAccountId()));
+    byte[] fp = PublicKeyStoreUtil.parseFingerprint(gpgKeyExtId);
     try (PublicKeyStore store = storeProvider.get()) {
-      long keyId = keyId(fp);
+      long keyId = PublicKeyStoreUtil.keyIdFromFingerprint(fp);
       for (PGPPublicKeyRing keyRing : store.get(keyId)) {
         PGPPublicKey key = keyRing.getPublicKey();
         if (Arrays.equals(key.getFingerprint(), fp)) {
@@ -131,10 +128,6 @@
     return gpgKeyExtId;
   }
 
-  static byte[] parseFingerprint(ExternalId gpgKeyExtId) {
-    return BaseEncoding.base16().decode(gpgKeyExtId.key().id());
-  }
-
   @Override
   public DynamicMap<RestView<GpgKey>> views() {
     return views;
@@ -145,29 +138,17 @@
     public Response<Map<String, GpgKeyInfo>> apply(AccountResource rsrc)
         throws PGPException, IOException, ResourceNotFoundException {
       checkVisible(self, rsrc);
-      Map<String, GpgKeyInfo> keys = new HashMap<>();
+      List<PGPPublicKey> keys =
+          publicKeyStoreUtil.listGpgKeysForUser(rsrc.getUser().getAccountId());
+      Map<String, GpgKeyInfo> res = new HashMap<>();
       try (PublicKeyStore store = storeProvider.get()) {
-        for (ExternalId extId : getGpgExtIds(rsrc)) {
-          byte[] fp = parseFingerprint(extId);
-          boolean found = false;
-          for (PGPPublicKeyRing keyRing : store.get(keyId(fp))) {
-            if (Arrays.equals(keyRing.getPublicKey().getFingerprint(), fp)) {
-              found = true;
-              GpgKeyInfo info =
-                  toJson(
-                      keyRing.getPublicKey(), checkerFactory.create(rsrc.getUser(), store), store);
-              keys.put(info.id, info);
-              info.id = null;
-              break;
-            }
-          }
-          if (!found) {
-            logger.atWarning().log(
-                "No public key stored for fingerprint %s", Fingerprint.toString(fp));
-          }
+        for (PGPPublicKey key : keys) {
+          GpgKeyInfo info = toJson(key, checkerFactory.create(rsrc.getUser(), store), store);
+          res.put(info.id, info);
+          info.id = null;
         }
       }
-      return Response.ok(keys);
+      return Response.ok(res);
     }
   }
 
@@ -194,14 +175,6 @@
     }
   }
 
-  private Iterable<ExternalId> getGpgExtIds(AccountResource rsrc) throws IOException {
-    return externalIds.byAccount(rsrc.getUser().getAccountId(), SCHEME_GPGKEY);
-  }
-
-  private static long keyId(byte[] fp) {
-    return NB.decodeInt64(fp, fp.length - 8);
-  }
-
   static void checkVisible(Provider<CurrentUser> self, AccountResource rsrc)
       throws ResourceNotFoundException {
     if (!BouncyCastleUtil.havePGP()) {
diff --git a/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index d51ee6a..886e4dd 100644
--- a/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -17,6 +17,8 @@
 import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
 import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.mail.EmailFactories.KEY_ADDED;
+import static com.google.gerrit.server.mail.EmailFactories.KEY_DELETED;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
@@ -46,6 +48,7 @@
 import com.google.gerrit.gpg.GerritPublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyStore;
+import com.google.gerrit.gpg.PublicKeyStoreUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -57,8 +60,7 @@
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.mail.send.AddKeySender;
-import com.google.gerrit.server.mail.send.DeleteKeySender;
+import com.google.gerrit.server.mail.EmailFactories;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.inject.Inject;
@@ -90,8 +92,7 @@
   private final Provider<CurrentUser> self;
   private final Provider<PublicKeyStore> storeProvider;
   private final GerritPublicKeyChecker.Factory checkerFactory;
-  private final AddKeySender.Factory addKeySenderFactory;
-  private final DeleteKeySender.Factory deleteKeySenderFactory;
+  private final EmailFactories emailFactories;
   private final Provider<InternalAccountQuery> accountQueryProvider;
   private final ExternalIds externalIds;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
@@ -105,8 +106,7 @@
       Provider<CurrentUser> self,
       Provider<PublicKeyStore> storeProvider,
       GerritPublicKeyChecker.Factory checkerFactory,
-      AddKeySender.Factory addKeySenderFactory,
-      DeleteKeySender.Factory deleteKeySenderFactory,
+      EmailFactories emailFactories,
       Provider<InternalAccountQuery> accountQueryProvider,
       ExternalIds externalIds,
       @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
@@ -117,8 +117,7 @@
     this.self = self;
     this.storeProvider = storeProvider;
     this.checkerFactory = checkerFactory;
-    this.addKeySenderFactory = addKeySenderFactory;
-    this.deleteKeySenderFactory = deleteKeySenderFactory;
+    this.emailFactories = emailFactories;
     this.accountQueryProvider = accountQueryProvider;
     this.externalIds = externalIds;
     this.accountsUpdateProvider = accountsUpdateProvider;
@@ -175,7 +174,8 @@
     for (String id : input.delete) {
       try {
         ExternalId gpgKeyExtId = GpgKeys.findGpgKey(id, existingExtIds);
-        fingerprints.put(gpgKeyExtId, new Fingerprint(GpgKeys.parseFingerprint(gpgKeyExtId)));
+        fingerprints.put(
+            gpgKeyExtId, new Fingerprint(PublicKeyStoreUtil.parseFingerprint(gpgKeyExtId)));
       } catch (ResourceNotFoundException e) {
         // Skip removal.
       }
@@ -261,7 +261,9 @@
         case FORCED:
           if (!addedKeys.isEmpty()) {
             try {
-              addKeySenderFactory.create(user, addedKeys).send();
+              emailFactories
+                  .createOutgoingEmail(KEY_ADDED, emailFactories.createAddKeyEmail(user, addedKeys))
+                  .send();
             } catch (EmailException e) {
               logger.atSevere().withCause(e).log(
                   "Cannot send GPG key added message to %s",
@@ -270,8 +272,11 @@
           }
           if (!toRemove.isEmpty()) {
             try {
-              deleteKeySenderFactory
-                  .create(user, toRemove.stream().map(Fingerprint::toString).collect(toList()))
+              emailFactories
+                  .createOutgoingEmail(
+                      KEY_DELETED,
+                      emailFactories.createDeleteKeyEmail(
+                          user, toRemove.stream().map(Fingerprint::toString).collect(toList())))
                   .send();
             } catch (EmailException e) {
               logger.atSevere().withCause(e).log(
diff --git a/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java b/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
index 77c5381..ca937fd 100644
--- a/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
+++ b/java/com/google/gerrit/httpd/NumericChangeIdRedirectServlet.java
@@ -14,6 +14,10 @@
 
 package com.google.gerrit.httpd;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -24,12 +28,23 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Arrays;
 import java.util.Optional;
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
-/** Redirects {@code domain.tld/123} to {@code domain.tld/c/project/+/123}. */
+/**
+ * Redirects:
+ *
+ * <ul>
+ *   <li>{@code domain.tld/123} to {@code domain.tld/c/project/+/123}
+ *   <li/>
+ *   <li>{@code domain.tld/123/comment/bc630c55_3e265b44} to {@code
+ *       domain.tld/c/project/+/123/comment/bc630c55_3e265b44/}
+ *   <li/>
+ * </ul>
+ */
 @Singleton
 public class NumericChangeIdRedirectServlet extends HttpServlet {
   private static final long serialVersionUID = 1L;
@@ -43,12 +58,16 @@
 
   @Override
   protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
-    String idString = req.getPathInfo();
-    if (idString.endsWith("/")) {
-      idString = idString.substring(0, idString.length() - 1);
-    }
+    String uriPath = req.getPathInfo();
+
+    ImmutableList<String> uriSegments =
+        Arrays.stream(uriPath.split("/", 2)).collect(toImmutableList());
+
+    String idString = uriSegments.get(0);
+    String finalSegment = (uriSegments.size() > 1) ? uriSegments.get(1) : null;
+
     Optional<Change.Id> id = Change.Id.tryParse(idString);
-    if (!id.isPresent()) {
+    if (id.isEmpty()) {
       rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
       return;
     }
@@ -64,6 +83,10 @@
     }
     String path =
         PageLinks.toChange(changeResource.getProject(), changeResource.getChange().getId());
-    UrlModule.toGerrit(path, req, rsp);
+    if (finalSegment != null) {
+      path += finalSegment;
+    }
+    String queryString = Strings.emptyToNull(req.getQueryString());
+    UrlModule.toGerrit(path + (queryString != null ? "?" + queryString : ""), req, rsp);
   }
 }
diff --git a/java/com/google/gerrit/httpd/UrlModule.java b/java/com/google/gerrit/httpd/UrlModule.java
index 69adf82..ee9353e 100644
--- a/java/com/google/gerrit/httpd/UrlModule.java
+++ b/java/com/google/gerrit/httpd/UrlModule.java
@@ -45,6 +45,9 @@
 class UrlModule extends ServletModule {
   private AuthConfig authConfig;
 
+  private static final String CHANGE_NUMBER_REGEX = "(?:/c)?/([1-9][0-9]*)";
+  private static final String PATCH_SET_REGEX = "([1-9][0-9]*(\\.\\.[1-9][0-9]*)?)";
+
   UrlModule(AuthConfig authConfig) {
     this.authConfig = authConfig;
   }
@@ -72,7 +75,12 @@
     serveRegex("^/settings/?$").with(screen(PageLinks.SETTINGS));
     serveRegex("^/register$").with(registerScreen(false));
     serveRegex("^/register/(.+)$").with(registerScreen(true));
-    serveRegex("^/([1-9][0-9]*)/?$").with(NumericChangeIdRedirectServlet.class);
+    serveRegex("^" + CHANGE_NUMBER_REGEX + "(/" + PATCH_SET_REGEX + ")?/?$")
+        .with(NumericChangeIdRedirectServlet.class);
+    serveRegex("^" + CHANGE_NUMBER_REGEX + "/" + PATCH_SET_REGEX + "?/[^+]+$")
+        .with(NumericChangeIdRedirectServlet.class);
+    serveRegex("^" + CHANGE_NUMBER_REGEX + "/comment/\\w+/?$")
+        .with(NumericChangeIdRedirectServlet.class);
     serveRegex("^/p/(.*)$").with(queryProjectNew());
     serveRegex("^/r/(.+)/?$").with(DirectChangeByCommit.class);
 
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index e3cc0a5..e1abcb1 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -51,15 +51,17 @@
 import com.google.gerrit.server.LibModuleType;
 import com.google.gerrit.server.ModuleOverloader;
 import com.google.gerrit.server.StartupChecks.StartupChecksModule;
+import com.google.gerrit.server.account.AccountCacheImpl;
 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.account.externalids.storage.notedb.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;
+import com.google.gerrit.server.change.AttentionSetOwnerAdder.AttentionSetOwnerAdderModule;
 import com.google.gerrit.server.change.ChangeCleanupRunner.ChangeCleanupRunnerModule;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.AuthConfigModule;
@@ -85,6 +87,7 @@
 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.EmailModule;
 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;
@@ -304,10 +307,11 @@
     modules.add(new EventBrokerModule());
     modules.add(new JdbcAccountPatchReviewStoreModule(config));
     modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
-    modules.add(new StreamEventsApiListenerModule());
+    modules.add(new StreamEventsApiListenerModule(config));
     modules.add(new SysExecutorModule());
     modules.add(new DiffExecutorModule());
     modules.add(new MimeUtil2Module());
+    modules.add(cfgInjector.getInstance(AccountCacheImpl.AccountCacheModule.class));
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new GerritApiModule());
     modules.add(new ProjectQueryBuilderModule());
@@ -319,6 +323,7 @@
     modules.add(new DefaultMemoryCacheModule());
     modules.add(new H2CacheModule());
     modules.add(cfgInjector.getInstance(MailReceiverModule.class));
+    modules.add(new EmailModule());
     modules.add(new SmtpEmailSenderModule());
     modules.add(new SignedTokenEmailTokenVerifierModule());
     modules.add(new LocalMergeSuperSetComputationModule());
@@ -360,6 +365,7 @@
           }
         });
     modules.add(new GarbageCollectionModule());
+    modules.add(new AttentionSetOwnerAdderModule());
     modules.add(new ChangeCleanupRunnerModule());
     modules.add(new AccountDeactivatorModule());
     modules.add(new DefaultProjectNameLockManagerModule());
diff --git a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
index 36fa61b..4c42e79 100644
--- a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
@@ -95,13 +95,15 @@
           ListChangesOption.ALL_COMMITS,
           ListChangesOption.ALL_REVISIONS,
           ListChangesOption.CHANGE_ACTIONS,
+          ListChangesOption.DETAILED_ACCOUNTS,
           ListChangesOption.DETAILED_LABELS,
           ListChangesOption.DOWNLOAD_COMMANDS,
           ListChangesOption.MESSAGES,
           ListChangesOption.SUBMITTABLE,
           ListChangesOption.WEB_LINKS,
           ListChangesOption.SKIP_DIFFSTAT,
-          ListChangesOption.SUBMIT_REQUIREMENTS);
+          ListChangesOption.SUBMIT_REQUIREMENTS,
+          ListChangesOption.PARENTS);
 
   @Nullable
   public static String getPath(@Nullable String requestedURL) throws URISyntaxException {
diff --git a/java/com/google/gerrit/httpd/raw/SiteStaticDirectoryServlet.java b/java/com/google/gerrit/httpd/raw/SiteStaticDirectoryServlet.java
index 594415a..bdc4f65 100644
--- a/java/com/google/gerrit/httpd/raw/SiteStaticDirectoryServlet.java
+++ b/java/com/google/gerrit/httpd/raw/SiteStaticDirectoryServlet.java
@@ -35,7 +35,7 @@
   SiteStaticDirectoryServlet(
       SitePaths site,
       @GerritServerConfig Config cfg,
-      @Named(StaticModule.CACHE) Cache<Path, Resource> cache) {
+      @Named(StaticModuleConstants.CACHE) Cache<Path, Resource> cache) {
     super(cache, cfg.getBoolean("site", "refreshHeaderFooter", true));
     Path p;
     try {
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 306973f..17ac095 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.httpd.raw;
 
+import static com.google.gerrit.httpd.raw.StaticModuleConstants.CACHE;
+import static com.google.gerrit.httpd.raw.StaticModuleConstants.POLYGERRIT_INDEX_PATHS;
 import static java.nio.file.Files.exists;
 import static java.nio.file.Files.isReadable;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
@@ -44,6 +47,7 @@
 import java.io.IOException;
 import java.nio.file.FileSystem;
 import java.nio.file.Path;
+import java.util.regex.Pattern;
 import javax.servlet.Filter;
 import javax.servlet.FilterChain;
 import javax.servlet.FilterConfig;
@@ -59,28 +63,18 @@
 
 public class StaticModule extends ServletModule {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  // This constant is copied and NOT reused from UrlModule because of the need for
+  // StaticModule and UrlModule to be used in isolation. The requirement comes
+  // from the way Google includes these two classes in their setup.
+  private static final String CHANGE_NUMBER_REGEX = "(?:/c)?/([1-9][0-9]*)";
+  // Regex matching the direct links to comments using only the change number
+  // 1234/comment/abc_def
+  public static final String CHANGE_NUMBER_URI_REGEX =
+      "^"
+          + CHANGE_NUMBER_REGEX
+          + "(/[1-9][0-9]*(\\.\\.[1-9][0-9]*)?(/[^+]*)?)?(/comment/[^+]+)?/?$";
 
-  public static final String CACHE = "static_content";
-
-  /**
-   * Paths at which we should serve the main PolyGerrit application {@code index.html}.
-   *
-   * <p>Supports {@code "/*"} as a trailing wildcard.
-   */
-  public static final ImmutableList<String> POLYGERRIT_INDEX_PATHS =
-      ImmutableList.of(
-          "/",
-          "/c/*",
-          "/id/*",
-          "/p/*",
-          "/q/*",
-          "/x/*",
-          "/admin/*",
-          "/dashboard/*",
-          "/profile/*",
-          "/groups/self",
-          "/settings/*",
-          "/Documentation/q/*");
+  private static final Pattern CHANGE_NUMBER_URI_PATTERN = Pattern.compile(CHANGE_NUMBER_URI_REGEX);
 
   /**
    * Paths that should be treated as static assets when serving PolyGerrit.
@@ -371,7 +365,7 @@
   }
 
   @Singleton
-  private static class PolyGerritFilter implements Filter {
+  protected static class PolyGerritFilter implements Filter {
     private final HttpServlet polyGerritIndex;
     private final PolyGerritUiServlet polygerritUI;
 
@@ -422,8 +416,13 @@
       return matchPath(POLYGERRIT_ASSET_PATHS, path);
     }
 
-    private static boolean isPolyGerritIndex(String path) {
-      return matchPath(POLYGERRIT_INDEX_PATHS, path);
+    @VisibleForTesting
+    protected static boolean isPolyGerritIndex(String path) {
+      return !isChangeNumberUri(path) && matchPath(POLYGERRIT_INDEX_PATHS, path);
+    }
+
+    private static boolean isChangeNumberUri(String path) {
+      return CHANGE_NUMBER_URI_PATTERN.matcher(path).matches();
     }
 
     private static boolean matchPath(Iterable<String> paths, String path) {
diff --git a/java/com/google/gerrit/httpd/raw/StaticModuleConstants.java b/java/com/google/gerrit/httpd/raw/StaticModuleConstants.java
new file mode 100644
index 0000000..f6ac544
--- /dev/null
+++ b/java/com/google/gerrit/httpd/raw/StaticModuleConstants.java
@@ -0,0 +1,50 @@
+// 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.httpd.raw;
+
+import com.google.common.collect.ImmutableList;
+
+/**
+ * Various constants related to {@link StaticModule}
+ *
+ * <p>Methods of the {@link StaticModule} are not used internally in google, so moving public
+ * constants into the {@link StaticModuleConstants} allows to exclude {@link StaticModule} from the
+ * google-hosted gerrit hosts.
+ */
+public final class StaticModuleConstants {
+  public static final String CACHE = "static_content";
+
+  /**
+   * Paths at which we should serve the main PolyGerrit application {@code index.html}.
+   *
+   * <p>Supports {@code "/*"} as a trailing wildcard.
+   */
+  public static final ImmutableList<String> POLYGERRIT_INDEX_PATHS =
+      ImmutableList.of(
+          "/",
+          "/c/*",
+          "/id/*",
+          "/p/*",
+          "/q/*",
+          "/x/*",
+          "/admin/*",
+          "/dashboard/*",
+          "/profile/*",
+          "/groups/self",
+          "/settings/*",
+          "/Documentation/q/*");
+
+  private StaticModuleConstants() {}
+}
diff --git a/java/com/google/gerrit/httpd/restapi/CorsResponder.java b/java/com/google/gerrit/httpd/restapi/CorsResponder.java
new file mode 100644
index 0000000..60dce61
--- /dev/null
+++ b/java/com/google/gerrit/httpd/restapi/CorsResponder.java
@@ -0,0 +1,175 @@
+// 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.httpd.restapi;
+
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_MAX_AGE;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD;
+import static com.google.common.net.HttpHeaders.AUTHORIZATION;
+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 javax.servlet.http.HttpServletResponse.SC_OK;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.util.http.CacheHeaders;
+import java.util.Locale;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.lib.Config;
+
+/** Provides methods for processing CORS requests. */
+public class CorsResponder {
+  private static final String PLAIN_TEXT = "text/plain";
+  private static final String X_GERRIT_AUTH = "X-Gerrit-Auth";
+  private static final String X_REQUESTED_WITH = "X-Requested-With";
+
+  static final ImmutableSet<String> ALLOWED_CORS_METHODS =
+      ImmutableSet.of("GET", "HEAD", "POST", "PUT", "DELETE");
+  private static final ImmutableSet<String> ALLOWED_CORS_REQUEST_HEADERS =
+      Stream.of(AUTHORIZATION, CONTENT_TYPE, X_GERRIT_AUTH, X_REQUESTED_WITH)
+          .map(s -> s.toLowerCase(Locale.US))
+          .collect(ImmutableSet.toImmutableSet());
+
+  private static boolean isCorsPreflight(HttpServletRequest req) {
+    return "OPTIONS".equals(req.getMethod())
+        && !Strings.isNullOrEmpty(req.getHeader(ORIGIN))
+        && !Strings.isNullOrEmpty(req.getHeader(ACCESS_CONTROL_REQUEST_METHOD));
+  }
+
+  @Nullable
+  public static Pattern makeAllowOrigin(Config cfg) {
+    String[] allow = cfg.getStringList("site", null, "allowOriginRegex");
+    if (allow.length > 0) {
+      return Pattern.compile(Joiner.on('|').join(allow));
+    }
+    return null;
+  }
+
+  @Nullable private final Pattern allowOrigin;
+
+  public CorsResponder(@Nullable Pattern allowOrigin) {
+    this.allowOrigin = allowOrigin;
+  }
+
+  /**
+   * Responses to a CORS preflight request.
+   *
+   * <p>If the request is a CORS preflight request, the method writes a correct preflight response
+   * and returns true. A further processing of the request is not required. Otherwise, the method
+   * returns false without adding anything to the response.
+   */
+  public boolean filterCorsPreflight(HttpServletRequest req, HttpServletResponse res)
+      throws BadRequestException {
+    if (!isCorsPreflight(req)) {
+      return false;
+    }
+    doCorsPreflight(req, res);
+    return true;
+  }
+
+  /**
+   * Processes CORS request and add required headers to the response.
+   *
+   * <p>The method checks if the incoming request is a CORS request and if so validates the
+   * request's origin.
+   */
+  public void checkCors(HttpServletRequest req, HttpServletResponse res, boolean isXd)
+      throws BadRequestException {
+    String origin = req.getHeader(ORIGIN);
+    if (isXd) {
+      // Cross-domain, non-preflighted requests must come from an approved origin.
+      if (Strings.isNullOrEmpty(origin) || !isOriginAllowed(origin)) {
+        throw new BadRequestException("origin not allowed");
+      }
+      res.addHeader(VARY, ORIGIN);
+      res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
+      res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
+    } else if (!Strings.isNullOrEmpty(origin)) {
+      // All other requests must be processed, but conditionally set CORS headers.
+      if (allowOrigin != null) {
+        res.addHeader(VARY, ORIGIN);
+      }
+      if (isOriginAllowed(origin)) {
+        setCorsHeaders(res, origin);
+      }
+    }
+  }
+
+  private void doCorsPreflight(HttpServletRequest req, HttpServletResponse res)
+      throws BadRequestException {
+    CacheHeaders.setNotCacheable(res);
+    setHeaderList(
+        res,
+        VARY,
+        ImmutableList.of(ORIGIN, ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS));
+
+    String origin = req.getHeader(ORIGIN);
+    if (Strings.isNullOrEmpty(origin) || !isOriginAllowed(origin)) {
+      throw new BadRequestException("CORS not allowed");
+    }
+
+    String method = req.getHeader(ACCESS_CONTROL_REQUEST_METHOD);
+    if (!ALLOWED_CORS_METHODS.contains(method)) {
+      throw new BadRequestException(method + " not allowed in CORS");
+    }
+
+    String headers = req.getHeader(ACCESS_CONTROL_REQUEST_HEADERS);
+    if (headers != null) {
+      for (String reqHdr : Splitter.on(',').trimResults().split(headers)) {
+        if (!ALLOWED_CORS_REQUEST_HEADERS.contains(reqHdr.toLowerCase(Locale.US))) {
+          throw new BadRequestException(reqHdr + " not allowed in CORS");
+        }
+      }
+    }
+
+    res.setStatus(SC_OK);
+    setCorsHeaders(res, origin);
+    res.setContentType(PLAIN_TEXT);
+    res.setContentLength(0);
+  }
+
+  private static void setCorsHeaders(HttpServletResponse res, String origin) {
+    res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
+    res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
+    res.setHeader(ACCESS_CONTROL_MAX_AGE, "600");
+    setHeaderList(
+        res,
+        ACCESS_CONTROL_ALLOW_METHODS,
+        Iterables.concat(ALLOWED_CORS_METHODS, ImmutableList.of("OPTIONS")));
+    setHeaderList(res, ACCESS_CONTROL_ALLOW_HEADERS, ALLOWED_CORS_REQUEST_HEADERS);
+  }
+
+  private static void setHeaderList(HttpServletResponse res, String name, Iterable<String> values) {
+    res.setHeader(name, Joiner.on(", ").join(values));
+  }
+
+  private boolean isOriginAllowed(String origin) {
+    return allowOrigin != null && allowOrigin.matcher(origin).matches();
+  }
+}
diff --git a/java/com/google/gerrit/httpd/restapi/ParameterParser.java b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
index 315c9c8..de53e64 100644
--- a/java/com/google/gerrit/httpd/restapi/ParameterParser.java
+++ b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.httpd.restapi;
 
-import static com.google.gerrit.httpd.restapi.RestApiServlet.ALLOWED_CORS_METHODS;
+import static com.google.gerrit.httpd.restapi.CorsResponder.ALLOWED_CORS_METHODS;
 import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_AUTHORIZATION;
 import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_CONTENT_TYPE;
 import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_METHOD;
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 4450edb..fd85c19 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -19,17 +19,7 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.flogger.LazyArgs.lazy;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_MAX_AGE;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD;
-import static com.google.common.net.HttpHeaders.AUTHORIZATION;
 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 java.math.RoundingMode.CEILING;
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -48,13 +38,11 @@
 import static javax.servlet.http.HttpServletResponse.SC_REQUEST_TIMEOUT;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.base.Throwables;
 import com.google.common.collect.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;
@@ -176,10 +164,10 @@
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Enumeration;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
-import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
@@ -187,7 +175,6 @@
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.regex.Pattern;
-import java.util.stream.Stream;
 import java.util.zip.GZIPOutputStream;
 import javax.servlet.ServletException;
 import javax.servlet.http.HttpServlet;
@@ -216,15 +203,6 @@
   @VisibleForTesting
   public static final String X_GERRIT_UPDATED_REF_ENABLED = "X-Gerrit-UpdatedRef-Enabled";
 
-  private static final String X_REQUESTED_WITH = "X-Requested-With";
-  private static final String X_GERRIT_AUTH = "X-Gerrit-Auth";
-  static final ImmutableSet<String> ALLOWED_CORS_METHODS =
-      ImmutableSet.of("GET", "HEAD", "POST", "PUT", "DELETE");
-  private static final ImmutableSet<String> ALLOWED_CORS_REQUEST_HEADERS =
-      Stream.of(AUTHORIZATION, CONTENT_TYPE, X_GERRIT_AUTH, X_REQUESTED_WITH)
-          .map(s -> s.toLowerCase(Locale.US))
-          .collect(ImmutableSet.toImmutableSet());
-
   public static final String XD_AUTHORIZATION = "access_token";
   public static final String XD_CONTENT_TYPE = "$ct";
   public static final String XD_METHOD = "$m";
@@ -302,25 +280,17 @@
       this.changeFinder = changeFinder;
       this.retryHelper = retryHelper;
       this.exceptionHooks = exceptionHooks;
-      allowOrigin = makeAllowOrigin(config);
+      allowOrigin = CorsResponder.makeAllowOrigin(config);
       this.injector = injector;
       this.dynamicBeans = dynamicBeans;
       this.deadlineCheckerFactory = deadlineCheckerFactory;
       this.cancellationMetrics = cancellationMetrics;
     }
-
-    @Nullable
-    private static Pattern makeAllowOrigin(Config cfg) {
-      String[] allow = cfg.getStringList("site", null, "allowOriginRegex");
-      if (allow.length > 0) {
-        return Pattern.compile(Joiner.on('|').join(allow));
-      }
-      return null;
-    }
   }
 
   private final Globals globals;
   private final Provider<RestCollection<RestResource, RestResource>> members;
+  private final CorsResponder corsResponder;
 
   public RestApiServlet(
       Globals globals, RestCollection<? extends RestResource, ? extends RestResource> members) {
@@ -335,6 +305,7 @@
         (Provider<RestCollection<RestResource, RestResource>>) requireNonNull((Object) members);
     this.globals = globals;
     this.members = n;
+    this.corsResponder = new CorsResponder(globals.allowOrigin);
   }
 
   @Override
@@ -358,7 +329,7 @@
 
       try (PerThreadCache ignored = PerThreadCache.create()) {
         List<IdString> path = splitPath(req);
-        RequestInfo requestInfo = createRequestInfo(traceContext, requestUri(req), path);
+        RequestInfo requestInfo = createRequestInfo(traceContext, req, requestUri, path);
         globals.requestListeners.runEach(l -> l.onRequest(requestInfo));
 
         // It's important that the PerformanceLogContext is closed before the response is sent to
@@ -375,13 +346,12 @@
                 new PerformanceLogContext(globals.config, globals.performanceLoggers)) {
           traceRequestData(req);
 
-          if (isCorsPreflight(req)) {
-            doCorsPreflight(req, res);
+          if (corsResponder.filterCorsPreflight(req, res)) {
             return;
           }
 
           qp = ParameterParser.getQueryParams(req);
-          checkCors(req, res, qp.hasXdOverride());
+          corsResponder.checkCors(req, res, qp.hasXdOverride());
           if (qp.hasXdOverride()) {
             req = applyXdOverrides(req, qp);
           }
@@ -635,6 +605,7 @@
             }
 
             statusCode = response.statusCode();
+            response.headers().forEach((k, v) -> res.setHeader(k, v));
             configureCaching(req, res, traceContext, rsrc, viewData, response.caching());
             res.setStatus(statusCode);
             logger.atFinest().log("REST call succeeded: %d", statusCode);
@@ -768,7 +739,8 @@
             if (status.isPresent()) {
               responseBytes = reply(req, res, e, status.get(), getUserMessages(e));
             } else {
-              responseBytes = replyInternalServerError(req, res, e, getUserMessages(e));
+              responseBytes =
+                  replyInternalServerError(req, res, e, getViewName(viewData), getUserMessages(e));
             }
           }
         }
@@ -1032,86 +1004,6 @@
     };
   }
 
-  private void checkCors(HttpServletRequest req, HttpServletResponse res, boolean isXd)
-      throws BadRequestException {
-    String origin = req.getHeader(ORIGIN);
-    if (isXd) {
-      // Cross-domain, non-preflighted requests must come from an approved origin.
-      if (Strings.isNullOrEmpty(origin) || !isOriginAllowed(origin)) {
-        throw new BadRequestException("origin not allowed");
-      }
-      res.addHeader(VARY, ORIGIN);
-      res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
-      res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
-    } else if (!Strings.isNullOrEmpty(origin)) {
-      // All other requests must be processed, but conditionally set CORS headers.
-      if (globals.allowOrigin != null) {
-        res.addHeader(VARY, ORIGIN);
-      }
-      if (isOriginAllowed(origin)) {
-        setCorsHeaders(res, origin);
-      }
-    }
-  }
-
-  private static boolean isCorsPreflight(HttpServletRequest req) {
-    return "OPTIONS".equals(req.getMethod())
-        && !Strings.isNullOrEmpty(req.getHeader(ORIGIN))
-        && !Strings.isNullOrEmpty(req.getHeader(ACCESS_CONTROL_REQUEST_METHOD));
-  }
-
-  private void doCorsPreflight(HttpServletRequest req, HttpServletResponse res)
-      throws BadRequestException {
-    CacheHeaders.setNotCacheable(res);
-    setHeaderList(
-        res,
-        VARY,
-        ImmutableList.of(ORIGIN, ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS));
-
-    String origin = req.getHeader(ORIGIN);
-    if (Strings.isNullOrEmpty(origin) || !isOriginAllowed(origin)) {
-      throw new BadRequestException("CORS not allowed");
-    }
-
-    String method = req.getHeader(ACCESS_CONTROL_REQUEST_METHOD);
-    if (!ALLOWED_CORS_METHODS.contains(method)) {
-      throw new BadRequestException(method + " not allowed in CORS");
-    }
-
-    String headers = req.getHeader(ACCESS_CONTROL_REQUEST_HEADERS);
-    if (headers != null) {
-      for (String reqHdr : Splitter.on(',').trimResults().split(headers)) {
-        if (!ALLOWED_CORS_REQUEST_HEADERS.contains(reqHdr.toLowerCase(Locale.US))) {
-          throw new BadRequestException(reqHdr + " not allowed in CORS");
-        }
-      }
-    }
-
-    res.setStatus(SC_OK);
-    setCorsHeaders(res, origin);
-    res.setContentType(PLAIN_TEXT);
-    res.setContentLength(0);
-  }
-
-  private static void setCorsHeaders(HttpServletResponse res, String origin) {
-    res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
-    res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
-    res.setHeader(ACCESS_CONTROL_MAX_AGE, "600");
-    setHeaderList(
-        res,
-        ACCESS_CONTROL_ALLOW_METHODS,
-        Iterables.concat(ALLOWED_CORS_METHODS, ImmutableList.of("OPTIONS")));
-    setHeaderList(res, ACCESS_CONTROL_ALLOW_HEADERS, ALLOWED_CORS_REQUEST_HEADERS);
-  }
-
-  private static void setHeaderList(HttpServletResponse res, String name, Iterable<String> values) {
-    res.setHeader(name, Joiner.on(", ").join(values));
-  }
-
-  private boolean isOriginAllowed(String origin) {
-    return globals.allowOrigin != null && globals.allowOrigin.matcher(origin).matches();
-  }
-
   private static String messageOr(Throwable t, String defaultMessage) {
     if (!Strings.isNullOrEmpty(t.getMessage())) {
       return t.getMessage();
@@ -1176,7 +1068,7 @@
     }
   }
 
-  private static <R extends RestResource> void setCacheHeaders(
+  private static void setCacheHeaders(
       HttpServletRequest req, HttpServletResponse res, CacheControl cacheControl) {
     if (isRead(req)) {
       switch (cacheControl.getType()) {
@@ -1742,7 +1634,9 @@
   private void checkUserSession(HttpServletRequest req) throws AuthException {
     CurrentUser user = globals.currentUser.get();
     if (isRead(req)) {
-      user.setAccessPath(AccessPath.REST_API);
+      if (user.getAccessPath().equals(AccessPath.UNKNOWN)) {
+        user.setAccessPath(AccessPath.REST_API);
+      }
     } else if (user instanceof AnonymousUser) {
       throw new AuthException("Authentication required");
     } else if (!globals.webSession.get().isAccessPathOk(AccessPath.REST_API)) {
@@ -1796,11 +1690,25 @@
   }
 
   private RequestInfo createRequestInfo(
-      TraceContext traceContext, String requestUri, List<IdString> path) {
+      TraceContext traceContext, HttpServletRequest req, String requestUri, List<IdString> path) {
     RequestInfo.Builder requestInfo =
         RequestInfo.builder(RequestInfo.RequestType.REST, globals.currentUser.get(), traceContext)
             .requestUri(requestUri);
 
+    if (req.getQueryString() != null) {
+      requestInfo.requestQueryString(req.getQueryString());
+    }
+
+    Enumeration<String> headerNames = req.getHeaderNames();
+    while (headerNames.hasMoreElements()) {
+      String headerName = headerNames.nextElement();
+      Enumeration<String> headerValues = req.getHeaders(headerName);
+      while (headerValues.hasMoreElements()) {
+        String headerValue = headerValues.nextElement();
+        requestInfo.addHeader(headerName, headerValue);
+      }
+    }
+
     if (path.size() < 1) {
       return requestInfo.build();
     }
@@ -1916,11 +1824,12 @@
       HttpServletRequest req,
       HttpServletResponse res,
       Throwable err,
+      String viewName,
       ImmutableList<String> userMessages)
       throws IOException {
     logger.atSevere().withCause(err).log(
-        "Error in %s %s: %s",
-        req.getMethod(), uriForLogging(req), globals.retryHelper.formatCause(err));
+        "Error in %s %s (view: %s): %s",
+        req.getMethod(), uriForLogging(req), viewName, globals.retryHelper.formatCause(err));
 
     StringBuilder msg = new StringBuilder("Internal server error");
     if (!userMessages.isEmpty()) {
@@ -1994,8 +1903,9 @@
       case CLIENT_CLOSED_REQUEST:
         return SC_CLIENT_CLOSED_REQUEST;
       case CLIENT_PROVIDED_DEADLINE_EXCEEDED:
-      case SERVER_DEADLINE_EXCEEDED:
         return SC_REQUEST_TIMEOUT;
+      case SERVER_DEADLINE_EXCEEDED:
+        return SC_INTERNAL_SERVER_ERROR;
     }
     logger.atSevere().log("Unexpected cancellation reason: %s", cancellationReason);
     return SC_INTERNAL_SERVER_ERROR;
diff --git a/java/com/google/gerrit/index/BUILD b/java/com/google/gerrit/index/BUILD
index ba1c8bd..8b48fc0 100644
--- a/java/com/google/gerrit/index/BUILD
+++ b/java/com/google/gerrit/index/BUILD
@@ -36,6 +36,7 @@
         "//lib/antlr:java-runtime",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
     ],
 )
diff --git a/java/com/google/gerrit/index/Index.java b/java/com/google/gerrit/index/Index.java
index 870d827..3ed76ba 100644
--- a/java/com/google/gerrit/index/Index.java
+++ b/java/com/google/gerrit/index/Index.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.IndexPredicate;
+import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import java.util.Optional;
@@ -158,12 +159,14 @@
   }
 
   /**
-   * Rewriter that should be invoked on queries to this index.
+   * An Optional filter that is invoked right after the results are returned from the index, but
+   * before any post-filter predicates.
    *
-   * <p>The default implementation does not do anything. Should be overridden by implementation, if
-   * needed.
+   * <p>The filter is invoked before any other index predicates. If the filter returns 'true', then
+   * other index predicates are evaluated. Otherwise, the result from the index is not returned to
+   * the DataSource.
    */
-  default IndexRewriter<V> getIndexRewriter() {
-    return (in, opts) -> in;
+  default Optional<Matchable<V>> getIndexFilter() {
+    return Optional.empty();
   }
 }
diff --git a/java/com/google/gerrit/index/IndexConfig.java b/java/com/google/gerrit/index/IndexConfig.java
index c21f32e..2141bf2 100644
--- a/java/com/google/gerrit/index/IndexConfig.java
+++ b/java/com/google/gerrit/index/IndexConfig.java
@@ -38,6 +38,7 @@
 
   public static Builder fromConfig(Config cfg) {
     Builder b = builder();
+    setIfPresent(cfg, "defaultLimit", b::defaultLimit);
     setIfPresent(cfg, "maxLimit", b::maxLimit);
     setIfPresent(cfg, "maxPages", b::maxPages);
     setIfPresent(cfg, "maxTerms", b::maxTerms);
@@ -67,6 +68,7 @@
 
   public static Builder builder() {
     return new AutoValue_IndexConfig.Builder()
+        .defaultLimit(Integer.MAX_VALUE)
         .maxLimit(Integer.MAX_VALUE)
         .maxPages(Integer.MAX_VALUE)
         .maxTerms(DEFAULT_MAX_TERMS)
@@ -79,6 +81,10 @@
 
   @AutoValue.Builder
   public abstract static class Builder {
+    public abstract Builder defaultLimit(int defaultLimit);
+
+    public abstract int defaultLimit();
+
     public abstract Builder maxLimit(int maxLimit);
 
     public abstract int maxLimit();
@@ -107,6 +113,7 @@
 
     public IndexConfig build() {
       IndexConfig cfg = autoBuild();
+      checkLimit(cfg.defaultLimit(), "defaultLimit");
       checkLimit(cfg.maxLimit(), "maxLimit");
       checkLimit(cfg.maxPages(), "maxPages");
       checkLimit(cfg.maxTerms(), "maxTerms");
@@ -121,6 +128,12 @@
   }
 
   /**
+   * Returns default limit for index queries, if the user does not provide one. If this is not set,
+   * then the max permitted limit for each user is used, which might be much higher than intended.
+   */
+  public abstract int defaultLimit();
+
+  /**
    * Returns maximum limit supported by the underlying index, or limited for performance reasons.
    */
   public abstract int maxLimit();
diff --git a/java/com/google/gerrit/index/QueryOptions.java b/java/com/google/gerrit/index/QueryOptions.java
index 40677a18..632e469 100644
--- a/java/com/google/gerrit/index/QueryOptions.java
+++ b/java/com/google/gerrit/index/QueryOptions.java
@@ -52,6 +52,38 @@
       int pageSizeMultiplier,
       int limit,
       Set<String> fields) {
+    return create(
+        config,
+        start,
+        searchAfter,
+        pageSize,
+        pageSizeMultiplier,
+        limit,
+        /* allowIncompleteResults= */ false,
+        fields);
+  }
+
+  public static QueryOptions create(
+      IndexConfig config,
+      int start,
+      int pageSize,
+      int pageSizeMultiplier,
+      int limit,
+      boolean allowIncompleteResults,
+      Set<String> fields) {
+    return create(
+        config, start, null, pageSize, pageSizeMultiplier, limit, allowIncompleteResults, fields);
+  }
+
+  public static QueryOptions create(
+      IndexConfig config,
+      int start,
+      Object searchAfter,
+      int pageSize,
+      int pageSizeMultiplier,
+      int limit,
+      boolean allowIncompleteResults,
+      Set<String> fields) {
     checkArgument(start >= 0, "start must be nonnegative: %s", start);
     checkArgument(limit > 0, "limit must be positive: %s", limit);
     if (searchAfter != null) {
@@ -64,6 +96,7 @@
         pageSize,
         pageSizeMultiplier,
         limit,
+        allowIncompleteResults,
         ImmutableSet.copyOf(fields));
   }
 
@@ -77,7 +110,15 @@
         Math.min(
             Math.min(Ints.saturatedCast((long) pageSize() + start()), config().maxPageSize()),
             backendLimit);
-    return create(config(), 0, null, pageSize, pageSizeMultiplier(), limit, fields());
+    return create(
+        config(),
+        0,
+        null,
+        pageSize,
+        pageSizeMultiplier(),
+        limit,
+        allowIncompleteResults(),
+        fields());
   }
 
   public abstract IndexConfig config();
@@ -93,28 +134,62 @@
 
   public abstract int limit();
 
+  /**
+   * When set to true, entities that fail to get parsed from the index are replaced with a canonical
+   * erroneous record. If false, parsing would throw an exception.
+   */
+  public abstract boolean allowIncompleteResults();
+
   public abstract ImmutableSet<String> fields();
 
   public QueryOptions withPageSize(int pageSize) {
     return create(
-        config(), start(), searchAfter(), pageSize, pageSizeMultiplier(), limit(), fields());
+        config(),
+        start(),
+        searchAfter(),
+        pageSize,
+        pageSizeMultiplier(),
+        limit(),
+        allowIncompleteResults(),
+        fields());
   }
 
   public QueryOptions withLimit(int newLimit) {
     return create(
-        config(), start(), searchAfter(), pageSize(), pageSizeMultiplier(), newLimit, fields());
+        config(),
+        start(),
+        searchAfter(),
+        pageSize(),
+        pageSizeMultiplier(),
+        newLimit,
+        allowIncompleteResults(),
+        fields());
   }
 
   public QueryOptions withStart(int newStart) {
     return create(
-        config(), newStart, searchAfter(), pageSize(), pageSizeMultiplier(), limit(), fields());
+        config(),
+        newStart,
+        searchAfter(),
+        pageSize(),
+        pageSizeMultiplier(),
+        limit(),
+        allowIncompleteResults(),
+        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())
+            config(),
+            start(),
+            newSearchAfter,
+            pageSize(),
+            pageSizeMultiplier(),
+            limit(),
+            allowIncompleteResults(),
+            fields())
         .withStart(0);
   }
 
@@ -126,6 +201,7 @@
         pageSize(),
         pageSizeMultiplier(),
         limit(),
+        allowIncompleteResults(),
         filter.apply(this));
   }
 
diff --git a/java/com/google/gerrit/index/Schema.java b/java/com/google/gerrit/index/Schema.java
index 974bb74..ab10d9e 100644
--- a/java/com/google/gerrit/index/Schema.java
+++ b/java/com/google/gerrit/index/Schema.java
@@ -291,7 +291,7 @@
    * @param skipFields set of field names to skip when indexing the document
    * @return all non-null field values from the object.
    */
-  public final Iterable<Values<T>> buildFields(T obj, ImmutableSet<String> skipFields) {
+  public final ImmutableList<Values<T>> buildFields(T obj, ImmutableSet<String> skipFields) {
     return schemaFields.values().stream()
         .map(f -> fieldValues(obj, f, skipFields))
         .filter(Objects::nonNull)
diff --git a/java/com/google/gerrit/index/project/ProjectField.java b/java/com/google/gerrit/index/project/ProjectField.java
index ff55546..29c920b 100644
--- a/java/com/google/gerrit/index/project/ProjectField.java
+++ b/java/com/google/gerrit/index/project/ProjectField.java
@@ -44,6 +44,9 @@
   public static final IndexedField<ProjectData, String>.SearchSpec NAME_SPEC =
       NAME_FIELD.exact("name");
 
+  public static final IndexedField<ProjectData, String>.SearchSpec PREFIX_NAME_SPEC =
+      NAME_FIELD.prefix("nameprefix");
+
   public static final IndexedField<ProjectData, String> DESCRIPTION_FIELD =
       IndexedField.<ProjectData>stringBuilder("Description")
           .stored()
@@ -59,6 +62,13 @@
   public static final IndexedField<ProjectData, String>.SearchSpec PARENT_NAME_SPEC =
       PARENT_NAME_FIELD.exact("parent_name");
 
+  public static final IndexedField<ProjectData, String> PARENT_NAME_2_FIELD =
+      IndexedField.<ProjectData>stringBuilder("ParentName2")
+          .build(p -> p.getParent().map(parent -> parent.getProject().getName()).orElse(null));
+
+  public static final IndexedField<ProjectData, String>.SearchSpec PARENT_NAME_2_SPEC =
+      PARENT_NAME_2_FIELD.exact("parent_name2");
+
   public static final IndexedField<ProjectData, Iterable<String>> NAME_PART_FIELD =
       IndexedField.<ProjectData>iterableStringBuilder("NamePart")
           .size(200)
diff --git a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
index 3ac594e..6cd43db 100644
--- a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
+++ b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
@@ -62,7 +62,24 @@
   @Deprecated static final Schema<ProjectData> V4 = schema(V3);
 
   // Upgrade Lucene to 7.x requires reindexing.
-  static final Schema<ProjectData> V5 = schema(V4);
+  @Deprecated static final Schema<ProjectData> V5 = schema(V4);
+
+  // Upgrade Lucene to 8.x requires reindexing.
+  @Deprecated static final Schema<ProjectData> V6 = schema(V5);
+
+  @Deprecated
+  static final Schema<ProjectData> V7 =
+      new Schema.Builder<ProjectData>()
+          .add(V6)
+          .addIndexedFields(ProjectField.PARENT_NAME_2_FIELD)
+          .addSearchSpecs(ProjectField.PARENT_NAME_2_SPEC)
+          .build();
+
+  static final Schema<ProjectData> V8 =
+      new Schema.Builder<ProjectData>()
+          .add(V7)
+          .addSearchSpecs(ProjectField.PREFIX_NAME_SPEC)
+          .build();
 
   /**
    * Name of the project index to be used when contacting index backends or loading configurations.
diff --git a/java/com/google/gerrit/index/project/ProjectSubstringPredicate.java b/java/com/google/gerrit/index/project/ProjectSubstringPredicate.java
new file mode 100644
index 0000000..fb6e5ae
--- /dev/null
+++ b/java/com/google/gerrit/index/project/ProjectSubstringPredicate.java
@@ -0,0 +1,40 @@
+// 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.index.project;
+
+import com.google.gerrit.index.query.PostFilterPredicate;
+import java.util.Locale;
+
+/** Predicate to match projects by a substring in the project name. */
+public class ProjectSubstringPredicate extends PostFilterPredicate<ProjectData> {
+
+  public ProjectSubstringPredicate(String fieldName, String value) {
+    super(fieldName, value);
+  }
+
+  @Override
+  public boolean match(ProjectData projectData) {
+    return projectData
+        .getProject()
+        .getName()
+        .toLowerCase(Locale.US)
+        .contains(getValue().toLowerCase(Locale.US));
+  }
+
+  @Override
+  public int getCost() {
+    return 2;
+  }
+}
diff --git a/java/com/google/gerrit/index/query/AndSource.java b/java/com/google/gerrit/index/query/AndSource.java
index 3adf881..6de0712 100644
--- a/java/com/google/gerrit/index/query/AndSource.java
+++ b/java/com/google/gerrit/index/query/AndSource.java
@@ -17,11 +17,12 @@
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.PaginationType;
 import java.util.Collection;
 import java.util.List;
 
 public class AndSource<T> extends AndPredicate<T> implements DataSource<T> {
-  protected final DataSource<T> source;
+  protected final FilteredSource<T> filteredSource;
 
   private final int start;
   private final int cardinality;
@@ -58,18 +59,18 @@
     if (selectedSource == null) {
       throw new IllegalArgumentException("No DataSource Found");
     }
-    this.source = toPaginatingSource(selectedSource);
+    this.filteredSource = toDataSource(selectedSource);
     this.cardinality = c;
   }
 
   @Override
   public ResultSet<T> read() {
-    return source.read();
+    return filteredSource.read();
   }
 
   @Override
   public ResultSet<FieldBundle> readRaw() {
-    return source.readRaw();
+    return filteredSource.readRaw();
   }
 
   @Override
@@ -91,17 +92,44 @@
   }
 
   @SuppressWarnings("unchecked")
-  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);
-      }
+  private FilteredSource<T> toDataSource(Predicate<T> pred) {
+    if (indexConfig.paginationType().equals(PaginationType.NONE)) {
+      return new DatasourceWithoutPagination((DataSource<T>) pred, start, indexConfig);
+    }
+    return new DatasourceWithPagination((DataSource<T>) pred, start, indexConfig);
+  }
 
-      @Override
-      protected boolean isMatchable() {
-        return AndSource.this.isMatchable();
-      }
-    };
+  private class DatasourceWithoutPagination extends FilteredSource<T> {
+
+    public DatasourceWithoutPagination(DataSource<T> source, int start, IndexConfig indexConfig) {
+      super(source, start, indexConfig);
+    }
+
+    @Override
+    protected boolean match(T object) {
+      return AndSource.this.match(object);
+    }
+
+    @Override
+    protected boolean isMatchable() {
+      return AndSource.this.isMatchable();
+    }
+  }
+
+  private class DatasourceWithPagination extends PaginatingSource<T> {
+
+    public DatasourceWithPagination(DataSource<T> source, int start, IndexConfig indexConfig) {
+      super(source, 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/FilteredSource.java b/java/com/google/gerrit/index/query/FilteredSource.java
new file mode 100644
index 0000000..8269793
--- /dev/null
+++ b/java/com/google/gerrit/index/query/FilteredSource.java
@@ -0,0 +1,120 @@
+// 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.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.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
+import java.util.ArrayList;
+import java.util.List;
+
+public class FilteredSource<T> implements DataSource<T> {
+
+  protected final DataSource<T> source;
+  protected final int start;
+  protected final int cardinality;
+  protected final IndexConfig indexConfig;
+  private static final int PARTITION_SIZE = 50;
+
+  public FilteredSource(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 defined.");
+    }
+    // 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.
+            Paginated<T> p = (Paginated<T>) source;
+            QueryOptions opts = p.getOptions();
+            final int limit = opts.limit();
+            int nextStart = pageResultSize;
+            while (pageResultSize == limit && r.size() < limit) {
+              ResultSet<T> next = p.restart(nextStart);
+              pageResultSize = 0;
+              for (T data : buffer(next)) {
+                if (match(data)) {
+                  r.add(data);
+                }
+                pageResultSize++;
+              }
+              nextStart += pageResultSize;
+            }
+          }
+
+          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() {
+    return source.readRaw();
+  }
+
+  protected Iterable<T> buffer(ResultSet<T> scanner) {
+    return FluentIterable.from(Iterables.partition(scanner, PARTITION_SIZE))
+        .transformAndConcat(this::transformBuffer);
+  }
+
+  protected List<T> transformBuffer(List<T> buffer) {
+    return buffer;
+  }
+
+  @Override
+  public int getCardinality() {
+    return cardinality;
+  }
+
+  protected boolean match(T object) {
+    return true;
+  }
+
+  protected boolean isMatchable() {
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/index/query/IndexPredicate.java b/java/com/google/gerrit/index/query/IndexPredicate.java
index 0bde640..e41742b 100644
--- a/java/com/google/gerrit/index/query/IndexPredicate.java
+++ b/java/com/google/gerrit/index/query/IndexPredicate.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 import java.util.Locale;
 import java.util.Objects;
-import java.util.Set;
 import java.util.stream.StreamSupport;
 
 /** Predicate that is mapped to a field in the index. */
@@ -103,8 +102,8 @@
     } else if (fieldTypeName.equals(FieldType.PREFIX.getName())) {
       return String.valueOf(fieldValueFromObject).startsWith(value);
     } else if (fieldTypeName.equals(FieldType.FULL_TEXT.getName())) {
-      Set<String> tokenizedField = tokenizeString(String.valueOf(fieldValueFromObject));
-      Set<String> tokenizedValue = tokenizeString(value);
+      ImmutableSet<String> tokenizedField = tokenizeString(String.valueOf(fieldValueFromObject));
+      ImmutableSet<String> tokenizedValue = tokenizeString(value);
       return !tokenizedValue.isEmpty() && tokenizedField.containsAll(tokenizedValue);
     } else if (fieldTypeName.equals(FieldType.STORED_ONLY.getName())) {
       throw new IllegalStateException("can't filter for storedOnly field " + getField().getName());
diff --git a/java/com/google/gerrit/index/query/InternalQuery.java b/java/com/google/gerrit/index/query/InternalQuery.java
index b6418a9..d610dbf 100644
--- a/java/com/google/gerrit/index/query/InternalQuery.java
+++ b/java/com/google/gerrit/index/query/InternalQuery.java
@@ -89,7 +89,7 @@
     return self();
   }
 
-  public final List<T> query(Predicate<T> p) {
+  public final ImmutableList<T> query(Predicate<T> p) {
     return queryResults(p).entities();
   }
 
diff --git a/java/com/google/gerrit/index/query/PaginatingSource.java b/java/com/google/gerrit/index/query/PaginatingSource.java
index 8a2d94e..19251ca 100644
--- a/java/com/google/gerrit/index/query/PaginatingSource.java
+++ b/java/com/google/gerrit/index/query/PaginatingSource.java
@@ -14,11 +14,7 @@
 
 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;
@@ -27,18 +23,10 @@
 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 class PaginatingSource<T> extends FilteredSource<T> {
 
   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;
+    super(source, start, indexConfig);
   }
 
   @Override
@@ -70,48 +58,28 @@
             Paginated<T> p = (Paginated<T>) source;
             QueryOptions opts = p.getOptions();
             final int limit = opts.limit();
-
-            // TODO: this fix is only for the stable branches and the real refactoring would be to
-            // restore the logic
-            // for the filtering in AndSource (L58 - 64) as per
-            // https://gerrit-review.googlesource.com/c/gerrit/+/345634/9
-            if (!indexConfig.paginationType().equals(PaginationType.NONE)) {
-              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++;
-                  if (r.size() > limit) {
-                    break;
-                  }
+            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);
                 }
-                nextStart += pageResultSize;
-                searchAfter = next.searchAfter();
-              }
-            } else {
-              int nextStart = pageResultSize;
-              while (pageResultSize == limit && r.size() < limit) {
-                ResultSet<T> next = p.restart(nextStart);
-                pageResultSize = 0;
-                for (T data : buffer(next)) {
-                  if (match(data)) {
-                    r.add(data);
-                  }
-                  pageResultSize++;
+                pageResultSize++;
+                if (r.size() > limit) {
+                  break;
                 }
-                nextStart += pageResultSize;
               }
+              nextStart += pageResultSize;
+              searchAfter = next.searchAfter();
             }
           }
 
@@ -130,34 +98,6 @@
     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 {
diff --git a/java/com/google/gerrit/index/query/Predicate.java b/java/com/google/gerrit/index/query/Predicate.java
index e251b00..fc9bc00 100644
--- a/java/com/google/gerrit/index/query/Predicate.java
+++ b/java/com/google/gerrit/index/query/Predicate.java
@@ -194,7 +194,7 @@
   @Override
   public abstract boolean equals(Object other);
 
-  private static class Any<T> extends Predicate<T> implements Matchable<T> {
+  public static class Any<T> extends Predicate<T> implements Matchable<T> {
     private static final Any<Object> INSTANCE = new Any<>();
 
     private Any() {}
diff --git a/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
index 1f8266a..341918c 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -26,12 +26,14 @@
 import com.google.common.collect.Ordering;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.index.IndexCollection;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.IndexRewriter;
+import com.google.gerrit.index.PaginationType;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.metrics.Description;
@@ -92,6 +94,7 @@
   private boolean enforceVisibility = true;
   private int userProvidedLimit;
   private boolean isNoLimit;
+  private boolean allowIncompleteResults;
   private Set<String> requestedFields;
 
   protected QueryProcessor(
@@ -163,6 +166,12 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
+  public QueryProcessor<T> setAllowIncompleteResults(boolean allowIncompleteResults) {
+    this.allowIncompleteResults = allowIncompleteResults;
+    return this;
+  }
+
   public QueryProcessor<T> setRequestedFields(Set<String> fields) {
     requestedFields = fields;
     return this;
@@ -270,12 +279,12 @@
                 // max for this user. The only way to see if there are more entities is to
                 // ask for one more result from the query.
                 // NOTE: This is consistent to the behaviour before the introduction of pagination.`
-                Ints.saturatedCast((long) limit + 1),
+                limit == getBackendSupportedLimit() ? limit : Ints.saturatedCast((long) limit + 1),
+                allowIncompleteResults,
                 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);
+        Predicate<T> pred = rewriter.rewrite(q, opts);
         if (enforceVisibility) {
           pred = enforceVisibility(pred);
         }
@@ -288,7 +297,9 @@
 
         @SuppressWarnings("unchecked")
         DataSource<T> s = (DataSource<T>) pred;
-        if (initialPageSize < limit && !(pred instanceof AndSource)) {
+        if (!indexConfig.paginationType().equals(PaginationType.NONE)
+            && initialPageSize < limit
+            && !(pred instanceof AndSource)) {
           s = new PaginatingSource<>(s, start, indexConfig);
         }
         sources.add(s);
@@ -302,16 +313,21 @@
 
       out = new ArrayList<>(cnt);
       for (int i = 0; i < cnt; i++) {
+        String queryString = queryStrings != null ? queryStrings.get(i) : null;
         ImmutableList<T> matchesList = matches.get(i).toList();
+        int matchCount = matchesList.size();
+        int limit = limits.get(i);
         logger.atFine().log(
             "Matches[%d]:\n%s",
             i, lazy(() -> matchesList.stream().map(this::formatForLogging).collect(toList())));
-        out.add(
-            QueryResult.create(
-                queryStrings != null ? queryStrings.get(i) : null,
-                predicates.get(i),
-                limits.get(i),
-                matchesList));
+        // TODO(brohlfs): Remove this extra logging by end of Q3 2023.
+        if (limit > 500 && userProvidedLimit <= 0 && matchCount > 100 && enforceVisibility) {
+          logger.atWarning().log(
+              "%s index query without provided limit. effective limit: %d, result count: %d, query:"
+                  + " %s",
+              schemaDef.getName(), getPermittedLimit(), matchCount, queryString);
+        }
+        out.add(QueryResult.create(queryString, predicates.get(i), limit, matchesList));
       }
 
       // Only measure successful queries that actually touched the index.
@@ -355,9 +371,16 @@
       int pageSize,
       int pageSizeMultiplier,
       int limit,
+      boolean allowIncompleteResults,
       Set<String> requestedFields) {
     return QueryOptions.create(
-        indexConfig, start, pageSize, pageSizeMultiplier, limit, requestedFields);
+        indexConfig,
+        start,
+        pageSize,
+        pageSizeMultiplier,
+        limit,
+        allowIncompleteResults,
+        requestedFields);
   }
 
   /**
@@ -411,6 +434,8 @@
     possibleLimits.add(getPermittedLimit());
     if (userProvidedLimit > 0) {
       possibleLimits.add(userProvidedLimit);
+    } else if (indexConfig.defaultLimit() > 0) {
+      possibleLimits.add(indexConfig.defaultLimit());
     }
     if (limitField != null) {
       Integer limitFromPredicate = LimitPredicate.getLimit(limitField, p);
diff --git a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
index ec60d02..41050b1 100644
--- a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
+++ b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
@@ -86,7 +86,7 @@
     this.indexName = indexName;
     this.indexedDocuments = new HashMap<>();
     this.queryCount = 0;
-    this.resultsSizes = new ArrayList<Integer>();
+    this.resultsSizes = new ArrayList<>();
   }
 
   @Override
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index 2fd1c45..938cd67 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -552,8 +552,7 @@
                 (long) getLimitBasedOnPaginationType(opts, opts.pageSize()) + opts.start());
         TopFieldDocs docs =
             opts.searchAfter() != null
-                ? searcher.searchAfter(
-                    (ScoreDoc) opts.searchAfter(), query, realLimit, sort, false, false)
+                ? searcher.searchAfter((ScoreDoc) opts.searchAfter(), query, realLimit, sort, 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++) {
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 57ef441..3f2a5ae 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -416,12 +416,7 @@
             if (maxRemainingHits > 0) {
               TopFieldDocs subIndexHits =
                   searchers[i].searchAfter(
-                      searchAfter,
-                      query,
-                      maxRemainingHits,
-                      sort,
-                      /* doDocScores= */ false,
-                      /* doMaxScore= */ false);
+                      searchAfter, query, maxRemainingHits, sort, /* doDocScores= */ false);
               searchAfterHitsCount += subIndexHits.scoreDocs.length;
               hits.add(subIndexHits);
               searchAfterBySubIndex.put(
diff --git a/java/com/google/gerrit/lucene/QueryBuilder.java b/java/com/google/gerrit/lucene/QueryBuilder.java
index 5b4b16f..f9e6b76 100644
--- a/java/com/google/gerrit/lucene/QueryBuilder.java
+++ b/java/com/google/gerrit/lucene/QueryBuilder.java
@@ -81,6 +81,8 @@
       return or(p);
     } else if (p instanceof NotPredicate) {
       return not(p);
+    } else if (p instanceof Predicate.Any) {
+      return new MatchAllDocsQuery();
     } else if (p instanceof IndexPredicate) {
       return fieldQuery((IndexPredicate<V>) p);
     } else if (p instanceof PostFilterPredicate) {
diff --git a/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java b/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
index ca750cd..998d838 100644
--- a/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
+++ b/java/com/google/gerrit/metrics/proc/ThreadMXBeanSun.java
@@ -21,6 +21,9 @@
 
   ThreadMXBeanSun(java.lang.management.ThreadMXBean sys) {
     this.sys = (ThreadMXBean) sys;
+    if (this.sys.isThreadAllocatedMemorySupported()) {
+      this.sys.setThreadAllocatedMemoryEnabled(true);
+    }
   }
 
   @Override
@@ -40,7 +43,7 @@
 
   @Override
   public boolean supportsAllocatedBytes() {
-    return true;
+    return sys.isThreadAllocatedMemorySupported();
   }
 
   @Override
diff --git a/java/com/google/gerrit/pgm/BUILD b/java/com/google/gerrit/pgm/BUILD
index df64bc7..8523e8a 100644
--- a/java/com/google/gerrit/pgm/BUILD
+++ b/java/com/google/gerrit/pgm/BUILD
@@ -20,6 +20,7 @@
         "//java/com/google/gerrit/httpd/auth/restapi",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index/project",
+        "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/launcher",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/lucene",
@@ -39,6 +40,7 @@
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/server/version",
         "//java/com/google/gerrit/sshd",
         "//lib:args4j",
         "//lib:guava",
@@ -56,5 +58,6 @@
         "//lib/prolog:cafeteria",
         "//lib/prolog:compiler",
         "//lib/prolog:runtime",
+        "@gson//jar",
     ],
 )
diff --git a/java/com/google/gerrit/pgm/ChangeExternalIdCaseSensitivity.java b/java/com/google/gerrit/pgm/ChangeExternalIdCaseSensitivity.java
index 2b7f23e..00e8fa4 100644
--- a/java/com/google/gerrit/pgm/ChangeExternalIdCaseSensitivity.java
+++ b/java/com/google/gerrit/pgm/ChangeExternalIdCaseSensitivity.java
@@ -16,15 +16,15 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.util.SiteProgram;
-import com.google.gerrit.server.account.externalids.DisabledExternalIdCache;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdCaseSensitivityMigrator;
-import com.google.gerrit.server.account.externalids.ExternalIdUpsertPreprocessor;
 import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.storage.notedb.DisabledExternalIdCache;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdCaseSensitivityMigrator;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNoteDbReadStorageModule;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNoteDbWriteStorageModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -84,7 +84,8 @@
                     new FactoryModuleBuilder()
                         .build(ExternalIdCaseSensitivityMigrator.Factory.class));
                 factory(MetaDataUpdate.InternalFactory.class);
-                DynamicMap.mapOf(binder(), ExternalIdUpsertPreprocessor.class);
+                install(new ExternalIdNoteDbReadStorageModule());
+                install(new ExternalIdNoteDbWriteStorageModule());
 
                 // The ChangeExternalIdCaseSensitivity program needs to access all external IDs only
                 // once to update them. After the update they are not accessed again. Hence the
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 72c465d..6230136 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -60,15 +60,19 @@
 import com.google.gerrit.server.LibModuleType;
 import com.google.gerrit.server.ModuleOverloader;
 import com.google.gerrit.server.StartupChecks.StartupChecksModule;
+import com.google.gerrit.server.account.AccountCacheImpl;
 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.account.externalids.storage.notedb.ExternalIdCaseSensitivityMigrator;
+import com.google.gerrit.server.account.storage.notedb.AccountNoteDbReadStorageModule;
+import com.google.gerrit.server.account.storage.notedb.AccountNoteDbWriteStorageModule;
 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;
+import com.google.gerrit.server.change.AttentionSetOwnerAdder.AttentionSetOwnerAdderModule;
 import com.google.gerrit.server.change.ChangeCleanupRunner.ChangeCleanupRunnerModule;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.AuthConfigModule;
@@ -94,10 +98,12 @@
 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.EmailModule;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier.SignedTokenEmailTokenVerifierModule;
 import com.google.gerrit.server.mail.receive.MailReceiver.MailReceiverModule;
 import com.google.gerrit.server.mail.send.SmtpEmailSender.SmtpEmailSenderModule;
 import com.google.gerrit.server.mime.MimeUtil2Module;
+import com.google.gerrit.server.notedb.RepoSequence.RepoSequenceModule;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.permissions.DefaultPermissionBackendModule;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
@@ -450,7 +456,7 @@
     modules.add(new SubscriptionGraphModule());
     modules.add(new SuperprojectUpdateSubmissionListenerModule());
     modules.add(new WorkQueueModule());
-    modules.add(new StreamEventsApiListenerModule());
+    modules.add(new StreamEventsApiListenerModule(config));
     modules.add(new EventBrokerModule());
     if (accountPatchReviewStoreModule != null) {
       modules.add(accountPatchReviewStoreModule);
@@ -460,6 +466,11 @@
     modules.add(new SysExecutorModule());
     modules.add(new DiffExecutorModule());
     modules.add(new MimeUtil2Module());
+    modules.add(cfgInjector.getInstance(AccountCacheImpl.AccountCacheModule.class));
+
+    modules.add(new AccountNoteDbWriteStorageModule());
+    modules.add(new AccountNoteDbReadStorageModule());
+    modules.add(new RepoSequenceModule());
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new GerritApiModule());
     modules.add(new ProjectQueryBuilderModule());
@@ -475,6 +486,7 @@
     modules.add(new DefaultMemoryCacheModule());
     modules.add(new H2CacheModule());
     modules.add(cfgInjector.getInstance(MailReceiverModule.class));
+    modules.add(new EmailModule());
     if (emailModule != null) {
       modules.add(emailModule);
     } else {
@@ -538,6 +550,7 @@
       modules.add(new PeriodicGroupIndexerModule());
     } else {
       modules.add(new AccountDeactivatorModule());
+      modules.add(new AttentionSetOwnerAdderModule());
       modules.add(new ChangeCleanupRunnerModule());
     }
     modules.add(new LocalMergeSuperSetComputationModule());
diff --git a/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java b/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
index b7ff1f7..6967fb1 100644
--- a/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
+++ b/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
@@ -20,11 +20,11 @@
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.pgm.util.SiteProgram;
-import com.google.gerrit.server.account.externalids.DisabledExternalIdCache;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
-import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.storage.notedb.DisabledExternalIdCache;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNotes;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index b4344d7..a2e780d 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -32,6 +32,8 @@
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.server.LibModuleLoader;
 import com.google.gerrit.server.ModuleOverloader;
+import com.google.gerrit.server.account.storage.notedb.AccountNoteDbReadStorageModule;
+import com.google.gerrit.server.account.storage.notedb.AccountNoteDbWriteStorageModule;
 import com.google.gerrit.server.cache.CacheDisplay;
 import com.google.gerrit.server.cache.CacheInfo;
 import com.google.gerrit.server.change.ChangeResource;
@@ -42,6 +44,7 @@
 import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.gerrit.server.index.options.BuildBloomFilter;
 import com.google.gerrit.server.index.options.IsFirstInsertForEntry;
+import com.google.gerrit.server.notedb.RepoSequence.RepoSequenceModule;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.gerrit.server.util.ReplicaUtil;
 import com.google.inject.AbstractModule;
@@ -224,6 +227,9 @@
             factory(ChangeResource.Factory.class);
           }
         });
+    modules.add(new AccountNoteDbWriteStorageModule());
+    modules.add(new AccountNoteDbReadStorageModule());
+    modules.add(new RepoSequenceModule());
 
     return dbInjector.createChildInjector(
         ModuleOverloader.override(
diff --git a/java/com/google/gerrit/pgm/SwitchSecureStore.java b/java/com/google/gerrit/pgm/SwitchSecureStore.java
index 6dec2d8..063fcdb 100644
--- a/java/com/google/gerrit/pgm/SwitchSecureStore.java
+++ b/java/com/google/gerrit/pgm/SwitchSecureStore.java
@@ -18,7 +18,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.IoUtil;
+import com.google.gerrit.common.JarUtil;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.SiteLibraryLoaderUtil;
 import com.google.gerrit.pgm.util.SiteProgram;
@@ -79,7 +79,7 @@
       return -1;
     }
 
-    IoUtil.loadJARs(newSecureStorePath);
+    JarUtil.loadJars(newSecureStorePath);
     SiteLibraryLoaderUtil.loadSiteLib(sitePaths.lib_dir);
 
     logger.atInfo().log(
diff --git a/java/com/google/gerrit/pgm/Version.java b/java/com/google/gerrit/pgm/Version.java
index 2392be5..27c52d3 100644
--- a/java/com/google/gerrit/pgm/Version.java
+++ b/java/com/google/gerrit/pgm/Version.java
@@ -14,18 +14,40 @@
 
 package com.google.gerrit.pgm;
 
+import com.google.gerrit.extensions.common.VersionInfo;
+import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.pgm.util.AbstractProgram;
+import com.google.gerrit.server.version.VersionInfoModule;
+import org.kohsuke.args4j.Option;
 
 /** Display the version of Gerrit. */
 public class Version extends AbstractProgram {
+
+  @Option(
+      name = "--verbose",
+      aliases = {"-v"},
+      usage = "verbose version info")
+  private boolean verbose;
+
+  @Option(name = "--json", usage = "json output format, assumes verbose output")
+  private boolean json;
+
   @Override
   public int run() throws Exception {
-    final String v = com.google.gerrit.common.Version.getVersion();
-    if (v == null) {
+    VersionInfo versionInfo = new VersionInfoModule().createVersionInfo();
+    if (versionInfo.gerritVersion == null) {
       System.err.println("fatal: version unavailable");
       return 1;
     }
-    System.out.println("gerrit version " + v);
+
+    if (json) {
+      System.out.println(OutputFormat.JSON.newGson().toJson(versionInfo));
+    } else if (verbose) {
+      System.out.print(versionInfo.verbose());
+    } else {
+      System.out.print(versionInfo.compact());
+    }
+
     return 0;
   }
 }
diff --git a/java/com/google/gerrit/pgm/init/AccountsOnInit.java b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
index be5fe1a..1a0e5d8 100644
--- a/java/com/google/gerrit/pgm/init/AccountsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
@@ -14,124 +14,12 @@
 
 package com.google.gerrit.pgm.init;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.Objects.requireNonNull;
-
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
-import com.google.gerrit.pgm.init.api.InitFlags;
-import com.google.gerrit.server.GerritPersonIdentProvider;
-import com.google.gerrit.server.account.AccountDelta;
-import com.google.gerrit.server.account.AccountProperties;
-import com.google.gerrit.server.account.Accounts;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.inject.Inject;
-import java.io.File;
 import java.io.IOException;
-import java.nio.file.Path;
-import org.eclipse.jgit.dircache.DirCache;
-import org.eclipse.jgit.dircache.DirCacheEditor;
-import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
-import org.eclipse.jgit.dircache.DirCacheEntry;
-import org.eclipse.jgit.internal.storage.file.FileRepository;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.FileMode;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.RefUpdate.Result;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.RepositoryCache.FileKey;
-import org.eclipse.jgit.util.FS;
 
-public class AccountsOnInit {
-  private final InitFlags flags;
-  private final SitePaths site;
-  private final String allUsers;
+public interface AccountsOnInit {
 
-  @Inject
-  public AccountsOnInit(InitFlags flags, SitePaths site, AllUsersNameOnInitProvider allUsers) {
-    this.flags = flags;
-    this.site = site;
-    this.allUsers = allUsers.get();
-  }
+  Account insert(Account.Builder account) throws IOException;
 
-  public Account insert(Account.Builder account) throws IOException {
-    File path = getPath();
-    try (Repository repo = new FileRepository(path);
-        ObjectInserter oi = repo.newObjectInserter()) {
-      PersonIdent ident =
-          new PersonIdent(new GerritPersonIdentProvider(flags.cfg).get(), account.registeredOn());
-
-      Config accountConfig = new Config();
-      AccountProperties.writeToAccountConfig(
-          AccountDelta.builder()
-              .setActive(!account.inactive())
-              .setFullName(account.fullName())
-              .setPreferredEmail(account.preferredEmail())
-              .setStatus(account.status())
-              .build(),
-          accountConfig);
-
-      DirCache newTree = DirCache.newInCore();
-      DirCacheEditor editor = newTree.editor();
-      final ObjectId blobId = oi.insert(Constants.OBJ_BLOB, accountConfig.toText().getBytes(UTF_8));
-      editor.add(
-          new PathEdit(AccountProperties.ACCOUNT_CONFIG) {
-            @Override
-            public void apply(DirCacheEntry ent) {
-              ent.setFileMode(FileMode.REGULAR_FILE);
-              ent.setObjectId(blobId);
-            }
-          });
-      editor.finish();
-
-      ObjectId treeId = newTree.writeTree(oi);
-
-      CommitBuilder cb = new CommitBuilder();
-      cb.setTreeId(treeId);
-      cb.setCommitter(ident);
-      cb.setAuthor(ident);
-      cb.setMessage("Create Account");
-      ObjectId id = oi.insert(cb);
-      oi.flush();
-
-      String refName = RefNames.refsUsers(account.id());
-      RefUpdate ru = repo.updateRef(refName);
-      ru.setExpectedOldObjectId(ObjectId.zeroId());
-      ru.setNewObjectId(id);
-      ru.setRefLogIdent(ident);
-      ru.setRefLogMessage("Create Account", false);
-      Result result = ru.update();
-      if (result != Result.NEW) {
-        throw new IOException(String.format("Failed to update ref %s: %s", refName, result.name()));
-      }
-      account.setMetaId(id.name());
-    }
-    return account.build();
-  }
-
-  public boolean hasAnyAccount() throws IOException {
-    File path = getPath();
-    if (path == null) {
-      return false;
-    }
-
-    try (Repository repo = new FileRepository(path)) {
-      return Accounts.hasAnyAccount(repo);
-    }
-  }
-
-  private File getPath() {
-    Path basePath = site.resolve(flags.cfg.getString("gerrit", null, "basePath"));
-    requireNonNull(basePath, "gerrit.basePath must be configured");
-    File file = basePath.resolve(allUsers).toFile();
-    File resolvedFile = FileKey.resolve(file, FS.DETECTED);
-    requireNonNull(resolvedFile, () -> String.format("%s does not exist", file.getAbsolutePath()));
-    return resolvedFile;
-  }
+  boolean hasAnyAccount() throws IOException;
 }
diff --git a/java/com/google/gerrit/pgm/init/AccountsOnInitNoteDbImpl.java b/java/com/google/gerrit/pgm/init/AccountsOnInitNoteDbImpl.java
new file mode 100644
index 0000000..e3e485f
--- /dev/null
+++ b/java/com/google/gerrit/pgm/init/AccountsOnInitNoteDbImpl.java
@@ -0,0 +1,137 @@
+// 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.pgm.init;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
+import com.google.gerrit.pgm.init.api.InitFlags;
+import com.google.gerrit.server.GerritPersonIdentProvider;
+import com.google.gerrit.server.account.AccountDelta;
+import com.google.gerrit.server.account.AccountProperties;
+import com.google.gerrit.server.account.storage.notedb.AccountsNoteDbRepoReader;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.dircache.DirCacheEntry;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.RepositoryCache;
+import org.eclipse.jgit.util.FS;
+
+public class AccountsOnInitNoteDbImpl implements AccountsOnInit {
+  private final InitFlags flags;
+  private final SitePaths site;
+  private final String allUsers;
+
+  @Inject
+  AccountsOnInitNoteDbImpl(InitFlags flags, SitePaths site, AllUsersNameOnInitProvider allUsers) {
+    this.flags = flags;
+    this.site = site;
+    this.allUsers = allUsers.get();
+  }
+
+  @Override
+  public Account insert(Account.Builder account) throws IOException {
+    File path = getPath();
+    try (Repository repo = new FileRepository(path);
+        ObjectInserter oi = repo.newObjectInserter()) {
+      PersonIdent ident =
+          new PersonIdent(new GerritPersonIdentProvider(flags.cfg).get(), account.registeredOn());
+
+      Config accountConfig = new Config();
+      AccountProperties.writeToAccountConfig(
+          AccountDelta.builder()
+              .setActive(!account.inactive())
+              .setFullName(account.fullName())
+              .setPreferredEmail(account.preferredEmail())
+              .setStatus(account.status())
+              .build(),
+          accountConfig);
+
+      DirCache newTree = DirCache.newInCore();
+      DirCacheEditor editor = newTree.editor();
+      final ObjectId blobId = oi.insert(Constants.OBJ_BLOB, accountConfig.toText().getBytes(UTF_8));
+      editor.add(
+          new DirCacheEditor.PathEdit(AccountProperties.ACCOUNT_CONFIG) {
+            @Override
+            public void apply(DirCacheEntry ent) {
+              ent.setFileMode(FileMode.REGULAR_FILE);
+              ent.setObjectId(blobId);
+            }
+          });
+      editor.finish();
+
+      ObjectId treeId = newTree.writeTree(oi);
+
+      CommitBuilder cb = new CommitBuilder();
+      cb.setTreeId(treeId);
+      cb.setCommitter(ident);
+      cb.setAuthor(ident);
+      cb.setMessage("Create Account");
+      ObjectId id = oi.insert(cb);
+      oi.flush();
+
+      String refName = RefNames.refsUsers(account.id());
+      RefUpdate ru = repo.updateRef(refName);
+      ru.setExpectedOldObjectId(ObjectId.zeroId());
+      ru.setNewObjectId(id);
+      ru.setRefLogIdent(ident);
+      ru.setRefLogMessage("Create Account", false);
+      RefUpdate.Result result = ru.update();
+      if (result != RefUpdate.Result.NEW) {
+        throw new IOException(String.format("Failed to update ref %s: %s", refName, result.name()));
+      }
+      account.setMetaId(id.name());
+    }
+    return account.build();
+  }
+
+  @Override
+  public boolean hasAnyAccount() throws IOException {
+    File path = getPath();
+    if (path == null) {
+      return false;
+    }
+
+    try (Repository repo = new FileRepository(path)) {
+      return AccountsNoteDbRepoReader.hasAnyAccount(repo);
+    }
+  }
+
+  private File getPath() {
+    Path basePath = site.resolve(flags.cfg.getString("gerrit", null, "basePath"));
+    requireNonNull(basePath, "gerrit.basePath must be configured");
+    File file = basePath.resolve(allUsers).toFile();
+    File resolvedFile = RepositoryCache.FileKey.resolve(file, FS.DETECTED);
+    requireNonNull(resolvedFile, () -> String.format("%s does not exist", file.getAbsolutePath()));
+    return resolvedFile;
+  }
+}
diff --git a/java/com/google/gerrit/pgm/init/BaseInit.java b/java/com/google/gerrit/pgm/init/BaseInit.java
index b59b924..abaefb2 100644
--- a/java/com/google/gerrit/pgm/init/BaseInit.java
+++ b/java/com/google/gerrit/pgm/init/BaseInit.java
@@ -21,7 +21,7 @@
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Die;
-import com.google.gerrit.common.IoUtil;
+import com.google.gerrit.common.JarUtil;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.IndexType;
@@ -331,7 +331,7 @@
                 "%s has more that one implementation of %s interface",
                 secureStore, SecureStore.class.getName()));
       }
-      IoUtil.loadJARs(secureStoreLib);
+      JarUtil.loadJars(secureStoreLib);
       return new SecureStoreInitData(secureStoreLib, secureStores.get(0));
     } catch (IOException e) {
       throw new InvalidSecureStoreException(String.format("%s is not a valid jar", secureStore), e);
diff --git a/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java b/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
index 35892f2..a056a08 100644
--- a/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
@@ -18,8 +18,8 @@
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdFactory;
-import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdFactoryNoteDbImpl;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNotes;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.SitePaths;
@@ -41,7 +41,7 @@
   private final InitFlags flags;
   private final SitePaths site;
   private final AllUsersName allUsers;
-  private final ExternalIdFactory externalIdFactory;
+  private final ExternalIdFactoryNoteDbImpl externalIdFactory;
   private final AuthConfig authConfig;
 
   @Inject
@@ -49,7 +49,7 @@
       InitFlags flags,
       SitePaths site,
       AllUsersNameOnInitProvider allUsers,
-      ExternalIdFactory externalIdFactory,
+      ExternalIdFactoryNoteDbImpl externalIdFactory,
       AuthConfig authConfig) {
     this.flags = flags;
     this.site = site;
diff --git a/java/com/google/gerrit/pgm/init/InitModule.java b/java/com/google/gerrit/pgm/init/InitModule.java
index 32c6697..f36ec3d 100644
--- a/java/com/google/gerrit/pgm/init/InitModule.java
+++ b/java/com/google/gerrit/pgm/init/InitModule.java
@@ -14,10 +14,20 @@
 
 package com.google.gerrit.pgm.init;
 
+import static com.google.gerrit.server.Sequence.LightweightAccounts;
+import static com.google.inject.Scopes.SINGLETON;
+
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.Section;
+import com.google.gerrit.pgm.init.api.SequencesOnInit.DisabledGitRefUpdatedRepoAccountsSequenceProvider;
+import com.google.gerrit.server.Sequence;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdFactoryNoteDbImpl;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Singleton;
 import com.google.inject.binder.LinkedBindingBuilder;
 import com.google.inject.internal.UniqueAnnotations;
 import java.lang.annotation.Annotation;
@@ -34,6 +44,10 @@
   @Override
   protected void configure() {
     bind(SitePaths.class);
+    bind(AllUsersName.class).toProvider(AllUsersNameProvider.class).in(SINGLETON);
+    bind(Sequence.class)
+        .annotatedWith(LightweightAccounts.class)
+        .toProvider(DisabledGitRefUpdatedRepoAccountsSequenceProvider.class);
     factory(Section.Factory.class);
     factory(VersionedAuthorizedKeysOnInit.Factory.class);
 
@@ -55,6 +69,9 @@
     step().to(InitCache.class);
     step().to(InitPlugins.class);
     step().to(InitDev.class);
+
+    bind(AccountsOnInit.class).to(AccountsOnInitNoteDbImpl.class);
+    bind(ExternalIdFactory.class).to(ExternalIdFactoryNoteDbImpl.class).in(Singleton.class);
   }
 
   protected LinkedBindingBuilder<InitStep> step() {
diff --git a/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
index a057e66..d0d03b5 100644
--- a/java/com/google/gerrit/pgm/init/SitePathInitializer.java
+++ b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
@@ -122,6 +122,8 @@
     extractMailExample("DeleteReviewerHtml.soy");
     extractMailExample("DeleteVote.soy");
     extractMailExample("DeleteVoteHtml.soy");
+    extractMailExample("Email.soy");
+    extractMailExample("EmailHtml.soy");
     extractMailExample("Footer.soy");
     extractMailExample("FooterHtml.soy");
     extractMailExample("ChangeHeader.soy");
diff --git a/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java b/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
index c11230c..68b1de7 100644
--- a/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
+++ b/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
@@ -14,34 +14,66 @@
 
 package com.google.gerrit.pgm.init.api;
 
-import com.google.gerrit.entities.Project;
+import static com.google.gerrit.server.Sequence.LightweightAccounts;
+
+import com.google.gerrit.server.Sequence;
+import com.google.gerrit.server.Sequences;
+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.notedb.RepoSequence;
-import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.notedb.RepoSequence.RepoSequenceModule;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class SequencesOnInit {
-  private final GitRepositoryManager repoManager;
-  private final AllUsersNameOnInitProvider allUsersName;
+  private final Sequence accountsSequence;
 
   @Inject
-  SequencesOnInit(GitRepositoryManagerOnInit repoManager, AllUsersNameOnInitProvider allUsersName) {
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
+  SequencesOnInit(@LightweightAccounts Sequence accountsSequence) {
+    this.accountsSequence = accountsSequence;
   }
 
   public int nextAccountId() {
-    RepoSequence accountSeq =
-        new RepoSequence(
-            repoManager,
-            GitReferenceUpdated.DISABLED,
-            Project.nameKey(allUsersName.get()),
-            Sequences.NAME_ACCOUNTS,
-            () -> Sequences.FIRST_ACCOUNT_ID,
-            1);
-    return accountSeq.next();
+    return accountsSequence.next();
+  }
+
+  /** A accounts sequence provider that does not fire git reference updates. */
+  public static class DisabledGitRefUpdatedRepoAccountsSequenceProvider
+      implements Provider<Sequence> {
+    private final GitRepositoryManager repoManager;
+    private final AllUsersName allUsers;
+    private final Config cfg;
+
+    @Inject
+    DisabledGitRefUpdatedRepoAccountsSequenceProvider(
+        @GerritServerConfig Config cfg,
+        GitRepositoryManagerOnInit repoManager,
+        AllUsersName allUsersName) {
+      this.repoManager = repoManager;
+      this.allUsers = allUsersName;
+      this.cfg = cfg;
+    }
+
+    @Override
+    public Sequence get() {
+      int accountBatchSize =
+          cfg.getInt(
+              RepoSequenceModule.SECTION_NOTE_DB,
+              Sequence.NAME_ACCOUNTS,
+              RepoSequenceModule.KEY_SEQUENCE_BATCH_SIZE,
+              RepoSequenceModule.DEFAULT_ACCOUNTS_SEQUENCE_BATCH_SIZE);
+      return new RepoSequence(
+          repoManager,
+          GitReferenceUpdated.DISABLED,
+          allUsers,
+          Sequence.NAME_ACCOUNTS,
+          () -> Sequences.FIRST_ACCOUNT_ID,
+          accountBatchSize);
+    }
   }
 }
diff --git a/java/com/google/gerrit/pgm/util/BUILD b/java/com/google/gerrit/pgm/util/BUILD
index f7c2b75..5b01c9c 100644
--- a/java/com/google/gerrit/pgm/util/BUILD
+++ b/java/com/google/gerrit/pgm/util/BUILD
@@ -15,6 +15,7 @@
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/cache/mem",
         "//java/com/google/gerrit/server/restapi",
+        "//java/com/google/gerrit/server/rules/prolog",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/util/cli",
         "//java/com/google/gerrit/util/logging",
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 1e41cbc..21ae8e1 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.ChangeDraftUpdate;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.DefaultRefLogIdentityProvider;
 import com.google.gerrit.server.IdentifiedUser;
@@ -42,7 +43,7 @@
 import com.google.gerrit.server.account.GroupIncludeCacheImpl;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.account.ServiceUserClassifierImpl;
-import com.google.gerrit.server.account.externalids.ExternalIdCacheModule;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdCacheModule;
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
@@ -72,6 +73,7 @@
 import com.google.gerrit.server.git.ChangesByProjectCache;
 import com.google.gerrit.server.git.PureRevertCache;
 import com.google.gerrit.server.git.TagCache;
+import com.google.gerrit.server.notedb.ChangeDraftNotesUpdate;
 import com.google.gerrit.server.notedb.NoteDbModule;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.patch.DiffOperationsImpl;
@@ -93,8 +95,8 @@
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule.DefaultSubmitRuleModule;
 import com.google.gerrit.server.rules.IgnoreSelfApprovalRule.IgnoreSelfApprovalRuleModule;
-import com.google.gerrit.server.rules.PrologModule;
 import com.google.gerrit.server.rules.SubmitRule;
+import com.google.gerrit.server.rules.prolog.PrologModule;
 import com.google.gerrit.server.submitrequirement.predicate.DistinctVotersPredicate;
 import com.google.gerrit.server.submitrequirement.predicate.FileEditsPredicate;
 import com.google.gerrit.server.submitrequirement.predicate.HasSubmoduleUpdatePredicate;
@@ -202,6 +204,7 @@
     factory(DistinctVotersPredicate.Factory.class);
     factory(HasSubmoduleUpdatePredicate.Factory.class);
     factory(ProjectState.Factory.class);
+    bind(ChangeDraftUpdate.ChangeDraftUpdateFactory.class).to(ChangeDraftNotesUpdate.Factory.class);
 
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class);
diff --git a/java/com/google/gerrit/proto/testing/BUILD b/java/com/google/gerrit/proto/testing/BUILD
index 0e5f887..069bb46 100644
--- a/java/com/google/gerrit/proto/testing/BUILD
+++ b/java/com/google/gerrit/proto/testing/BUILD
@@ -9,6 +9,7 @@
     deps = [
         "//lib:guava",
         "//lib/commons:lang3",
+        "//lib/guice",
         "//lib/truth",
     ],
 )
diff --git a/java/com/google/gerrit/proto/testing/SerializedClassSubject.java b/java/com/google/gerrit/proto/testing/SerializedClassSubject.java
index 79affc6..1264478 100644
--- a/java/com/google/gerrit/proto/testing/SerializedClassSubject.java
+++ b/java/com/google/gerrit/proto/testing/SerializedClassSubject.java
@@ -20,6 +20,7 @@
 
 import com.google.common.truth.FailureMetadata;
 import com.google.common.truth.Subject;
+import com.google.inject.TypeLiteral;
 import java.lang.reflect.Field;
 import java.lang.reflect.Method;
 import java.lang.reflect.Modifier;
@@ -61,10 +62,12 @@
   }
 
   private final Class<?> clazz;
+  private final TypeLiteral<?> clazzTl;
 
   private SerializedClassSubject(FailureMetadata metadata, Class<?> clazz) {
     super(metadata, clazz);
     this.clazz = clazz;
+    this.clazzTl = TypeLiteral.get(clazz);
   }
 
   public void isAbstract() {
@@ -87,7 +90,7 @@
         .that(
             FieldUtils.getAllFieldsList(clazz).stream()
                 .filter(f -> !Modifier.isStatic(f.getModifiers()))
-                .collect(toImmutableMap(Field::getName, Field::getGenericType)))
+                .collect(toImmutableMap(Field::getName, f -> clazzTl.getFieldType(f).getType())))
         .containsExactlyEntriesIn(expectedFields);
   }
 
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 2be3383..000f095 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -14,6 +14,8 @@
     "account/externalids/testing/ExternalIdTestUtil.java",
 ]
 
+PROLOG_SRC = ["rules/prolog/*.java"]
+
 java_library(
     name = "constants",
     srcs = CONSTANTS_SRC,
@@ -30,7 +32,8 @@
     name = "server",
     srcs = glob(
         ["**/*.java"],
-        exclude = CONSTANTS_SRC + GERRIT_GLOBAL_MODULE_SRC + TESTING_SRC,
+        exclude = CONSTANTS_SRC + GERRIT_GLOBAL_MODULE_SRC + TESTING_SRC +
+                  PROLOG_SRC,
     ),
     resource_strip_prefix = "resources",
     resources = ["//resources/com/google/gerrit/server"],
@@ -68,31 +71,7 @@
         "//lib:autolink",
         "//lib:automaton",
         "//lib:blame-cache",
-        "//lib:flexmark",
-        "//lib:flexmark-ext-abbreviation",
-        "//lib:flexmark-ext-anchorlink",
-        "//lib:flexmark-ext-autolink",
-        "//lib:flexmark-ext-definition",
-        "//lib:flexmark-ext-emoji",
-        "//lib:flexmark-ext-escaped-character",
-        "//lib:flexmark-ext-footnotes",
-        "//lib:flexmark-ext-gfm-issues",
-        "//lib:flexmark-ext-gfm-strikethrough",
-        "//lib:flexmark-ext-gfm-tables",
-        "//lib:flexmark-ext-gfm-tasklist",
-        "//lib:flexmark-ext-gfm-users",
-        "//lib:flexmark-ext-ins",
-        "//lib:flexmark-ext-jekyll-front-matter",
-        "//lib:flexmark-ext-superscript",
-        "//lib:flexmark-ext-tables",
-        "//lib:flexmark-ext-toc",
-        "//lib:flexmark-ext-typographic",
-        "//lib:flexmark-ext-wikilink",
-        "//lib:flexmark-ext-yaml-front-matter",
-        "//lib:flexmark-formatter",
-        "//lib:flexmark-html-parser",
-        "//lib:flexmark-profile-pegdown",
-        "//lib:flexmark-util",
+        "//lib:flexmark-all-lib",
         "//lib:gson",
         "//lib:guava",
         "//lib:guava-retrying",
@@ -101,6 +80,7 @@
         "//lib:juniversalchardet",
         "//lib:mime-util",
         "//lib:protobuf",
+        "//lib:roaringbitmap",
         "//lib:servlet-api",
         "//lib:soy",
         "//lib:tukaani-xz",
@@ -151,6 +131,8 @@
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
+        "//java/com/google/gerrit/server/rules/prolog",
+        "//java/com/google/gerrit/server/version",
         "//lib:blame-cache",
         "//lib:guava",
         "//lib:jgit",
diff --git a/java/com/google/gerrit/server/ChangeDraftUpdate.java b/java/com/google/gerrit/server/ChangeDraftUpdate.java
new file mode 100644
index 0000000..eb33fb5
--- /dev/null
+++ b/java/com/google/gerrit/server/ChangeDraftUpdate.java
@@ -0,0 +1,70 @@
+// 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;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import java.time.Instant;
+import java.util.List;
+import org.eclipse.jgit.lib.PersonIdent;
+
+/** An interface for updating draft comments. */
+public interface ChangeDraftUpdate {
+
+  interface ChangeDraftUpdateFactory {
+    ChangeDraftUpdate create(
+        ChangeNotes notes,
+        Account.Id accountId,
+        Account.Id realAccountId,
+        PersonIdent authorIdent,
+        Instant when);
+
+    ChangeDraftUpdate create(
+        Change change,
+        Account.Id accountId,
+        Account.Id realAccountId,
+        PersonIdent authorIdent,
+        Instant when);
+  }
+
+  /** Creates a draft comment. */
+  void putDraftComment(HumanComment c);
+
+  /**
+   * Marks a comment for deletion. Called when the comment is deleted because the user published it.
+   *
+   * <p>NOTE for implementers: The actual deletion of a published draft should only happen after the
+   * published comment is successfully updated. For more context, see {@link
+   * com.google.gerrit.server.notedb.NoteDbUpdateManager#execute(boolean)}.
+   *
+   * <p>TODO(nitzan) - add generalized support for the above sync issue. The implementation should
+   * support deletion of published drafts from multiple ChangeDraftUpdateFactory instances.
+   */
+  void markDraftCommentAsPublished(HumanComment c);
+
+  /**
+   * Marks a comment for deletion. Called when the comment is deleted because the user removed it.
+   */
+  void addDraftCommentForDeletion(HumanComment c);
+
+  /**
+   * Marks all comments for deletion. Called when there are inconsistencies between the published
+   * comments storage and the drafts one.
+   */
+  void addAllDraftCommentsForDeletion(List<Comment> comments);
+}
diff --git a/java/com/google/gerrit/server/ChangeUtil.java b/java/com/google/gerrit/server/ChangeUtil.java
index 2265055..dd86f88 100644
--- a/java/com/google/gerrit/server/ChangeUtil.java
+++ b/java/com/google/gerrit/server/ChangeUtil.java
@@ -22,10 +22,13 @@
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.util.CommitMessageUtil;
+import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.security.SecureRandom;
@@ -38,6 +41,7 @@
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Stream;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -54,6 +58,16 @@
   public static final Ordering<PatchSet> PS_ID_ORDER =
       Ordering.from(comparingInt(PatchSet::number));
 
+  private final DynamicItem<UrlFormatter> urlFormatter;
+  private final boolean enableLinkChangeIdFooters;
+
+  @Inject
+  ChangeUtil(DynamicItem<UrlFormatter> urlFormatter, @GerritServerConfig Config config) {
+    this.urlFormatter = urlFormatter;
+    this.enableLinkChangeIdFooters =
+        config.getBoolean("receive", "enableChangeIdLinkFooters", true);
+  }
+
   /** Returns a new unique identifier for change message entities. */
   public static String messageUuid() {
     byte[] buf = new byte[8];
@@ -124,11 +138,8 @@
    * @throws ResourceConflictException if the new commit message has a missing or invalid Change-Id
    * @throws BadRequestException if the new commit message is null or empty
    */
-  public static void ensureChangeIdIsCorrect(
-      boolean requireChangeId,
-      String currentChangeId,
-      String newCommitMessage,
-      UrlFormatter urlFormatter)
+  public void ensureChangeIdIsCorrect(
+      boolean requireChangeId, String currentChangeId, String newCommitMessage)
       throws ResourceConflictException, BadRequestException {
     RevCommit revCommit =
         RevCommit.parse(
@@ -137,7 +148,7 @@
     // Check that the commit message without footers is not empty
     CommitMessageUtil.checkAndSanitizeCommitMessage(revCommit.getShortMessage());
 
-    List<String> changeIdFooters = getChangeIdsFromFooter(revCommit, urlFormatter);
+    List<String> changeIdFooters = getChangeIdsFromFooter(revCommit);
     if (requireChangeId && changeIdFooters.isEmpty()) {
       throw new ResourceConflictException("missing Change-Id footer");
     }
@@ -155,9 +166,13 @@
 
   private static final Pattern LINK_CHANGE_ID_PATTERN = Pattern.compile("I[0-9a-f]{40}");
 
-  public static List<String> getChangeIdsFromFooter(RevCommit c, UrlFormatter urlFormatter) {
+  public List<String> getChangeIdsFromFooter(RevCommit c) {
     List<String> changeIds = c.getFooterLines(FooterConstants.CHANGE_ID);
-    Optional<String> webUrl = urlFormatter.getWebUrl();
+    if (!enableLinkChangeIdFooters) {
+      return changeIds;
+    }
+
+    Optional<String> webUrl = urlFormatter.get().getWebUrl();
     if (!webUrl.isPresent()) {
       return changeIds;
     }
@@ -176,6 +191,4 @@
 
     return changeIds;
   }
-
-  private ChangeUtil() {}
 }
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index 85ad6cc..06813d1 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -54,11 +54,10 @@
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
-import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -207,12 +206,6 @@
         .findFirst();
   }
 
-  public Optional<HumanComment> getDraft(ChangeNotes notes, IdentifiedUser user, Comment.Key key) {
-    return draftByChangeAuthor(notes, user.getAccountId()).stream()
-        .filter(c -> key.equals(c.key))
-        .findFirst();
-  }
-
   public List<HumanComment> publishedHumanCommentsByChange(ChangeNotes notes) {
     notes.load();
     return sort(Lists.newArrayList(notes.getHumanComments().values()));
@@ -227,30 +220,6 @@
     return robotCommentsByChange(notes).stream().filter(c -> c.key.uuid.equals(uuid)).findFirst();
   }
 
-  public List<HumanComment> draftByChange(ChangeNotes notes) {
-    List<HumanComment> comments = new ArrayList<>();
-    for (Ref ref : getDraftRefs(getVirtualId(notes))) {
-      Account.Id account = Account.Id.fromRefSuffix(ref.getName());
-      if (account != null) {
-        comments.addAll(draftByChangeAuthor(notes, account));
-      }
-    }
-    return sort(comments);
-  }
-
-  public List<HumanComment> byPatchSet(ChangeNotes notes, PatchSet.Id psId) {
-    List<HumanComment> comments = new ArrayList<>();
-    comments.addAll(publishedByPatchSet(notes, psId));
-
-    for (Ref ref : getDraftRefs(notes.getChangeId())) {
-      Account.Id account = Account.Id.fromRefSuffix(ref.getName());
-      if (account != null) {
-        comments.addAll(draftByPatchSetAuthor(psId, account, notes));
-      }
-    }
-    return sort(comments);
-  }
-
   public List<HumanComment> publishedByChangeFile(ChangeNotes notes, String file) {
     return commentsOnFile(notes.load().getHumanComments().values(), file);
   }
@@ -277,28 +246,50 @@
       List<? extends CommentInfo> comments,
       List<ChangeMessage> changeMessages,
       boolean skipAutoGeneratedMessages) {
+
+    // First sort by timestamp, then by authorId so that we could move on to the next change message
+    // in case multiple accounts left comments at the same timestamp.
     ArrayList<ChangeMessage> sortedChangeMessages =
         changeMessages.stream()
-            .sorted(comparing(ChangeMessage::getWrittenOn))
+            .sorted(
+                comparing(ChangeMessage::getWrittenOn)
+                    .thenComparingInt(c -> c.getAuthor() == null ? 0 : c.getAuthor().get()))
             .collect(toCollection(ArrayList::new));
 
     ArrayList<CommentInfo> sortedCommentInfos =
-        comments.stream().sorted(comparing(c -> c.updated)).collect(toCollection(ArrayList::new));
+        comments.stream()
+            .sorted(
+                comparing(CommentInfo::getUpdated)
+                    .thenComparingInt(c -> c.author == null ? 0 : c.author._accountId))
+            .collect(toCollection(ArrayList::new));
 
     int cmItr = 0;
+    int lastMatch = 0;
     for (CommentInfo comment : sortedCommentInfos) {
       // Keep advancing the change message pointer until we associate the comment to the next change
       // message in timestamp
       while (cmItr < sortedChangeMessages.size()) {
         ChangeMessage cm = sortedChangeMessages.get(cmItr);
-        if (isAfter(comment, cm) || (skipAutoGeneratedMessages && isAutoGenerated(cm))) {
+        if (isAfter(comment, cm)
+            || !haveSameAuthor(cm, comment)
+            || (skipAutoGeneratedMessages && isAutoGenerated(cm))) {
           cmItr += 1;
         } else {
+          lastMatch = cmItr;
           break;
         }
       }
       if (cmItr < changeMessages.size()) {
         comment.changeMessageId = sortedChangeMessages.get(cmItr).getKey().uuid();
+      } else {
+        // In case of no match "cmItr" will never be less than "changeMessages" size, hence the
+        // changeMessageId won't be set for any comment.
+        //
+        // Reset the search to the last succesful match, since we can't assume there will always be
+        // a match between change messages and comments. This could be the case of imported changes.
+        //
+        // More details here: https://issues.gerritcodereview.com/issues/318079520
+        cmItr = lastMatch;
       }
     }
   }
@@ -313,6 +304,12 @@
     return c.getUpdated().isAfter(cm.getWrittenOn());
   }
 
+  private static boolean haveSameAuthor(ChangeMessage cm, CommentInfo comment) {
+    return Objects.equals(
+        Optional.ofNullable(cm.getAuthor()).map(a -> a.get()),
+        Optional.ofNullable(comment.author).map(a -> a._accountId));
+  }
+
   /**
    * For the commit message the A side in a diff view is always empty when a comparison against an
    * ancestor is done, so there can't be any comments on this ancestor. However earlier we showed
@@ -328,19 +325,12 @@
 
   public List<HumanComment> draftByPatchSetAuthor(
       PatchSet.Id psId, Account.Id author, ChangeNotes notes) {
-    return commentsOnPatchSet(
-        notes.load().getDraftComments(author, getVirtualId(notes)).values(), psId);
-  }
-
-  public List<HumanComment> draftByChangeFileAuthor(
-      ChangeNotes notes, String file, Account.Id author) {
-    return commentsOnFile(
-        notes.load().getDraftComments(author, getVirtualId(notes)).values(), file);
+    return commentsOnPatchSet(notes.load().getDraftComments(author, getVirtualId(notes)), psId);
   }
 
   public List<HumanComment> draftByChangeAuthor(ChangeNotes notes, Account.Id author) {
     List<HumanComment> comments = new ArrayList<>();
-    comments.addAll(notes.getDraftComments(author, getVirtualId(notes)).values());
+    comments.addAll(notes.getDraftComments(author, getVirtualId(notes)));
     return sort(comments);
   }
 
@@ -392,12 +382,13 @@
   }
 
   public void setCommentCommitId(Comment c, Change change, PatchSet ps) {
-    checkArgument(
-        c.key.patchSetId == ps.id().get(),
-        "cannot set commit ID for patch set %s on comment %s",
-        ps.id(),
-        c);
     if (c.getCommitId() == null) {
+      checkArgument(
+          c.key.patchSetId == ps.id().get(),
+          "cannot set commit ID for patch set %s on comment %s",
+          ps.id(),
+          c);
+
       // This code is very much down into our stack and shouldn't be used for validation. Hence,
       // don't throw an exception here if we can't find a commit for the indicated side but
       // simply use the all-null ObjectId.
@@ -475,36 +466,11 @@
     }
   }
 
-  /** returns all changes that contain draft comments of {@code accountId}. */
-  public Collection<Change.Id> getChangesWithDrafts(Account.Id accountId) {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      return getChangesWithDrafts(repo, accountId);
-    } catch (IOException e) {
-      throw new StorageException(e);
-    }
-  }
-
   private Collection<Ref> getDraftRefs(Repository repo, Change.Id virtualId) throws IOException {
     return repo.getRefDatabase().getRefsByPrefix(RefNames.refsDraftCommentsPrefix(virtualId));
   }
 
-  private Collection<Change.Id> getChangesWithDrafts(Repository repo, Account.Id accountId)
-      throws IOException {
-    Set<Change.Id> changes = new HashSet<>();
-    for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_DRAFT_COMMENTS)) {
-      Integer accountIdFromRef = RefNames.parseRefSuffix(ref.getName());
-      if (accountIdFromRef != null && accountIdFromRef == accountId.get()) {
-        Change.Id changeId = Change.Id.fromAllUsersRef(ref.getName());
-        if (changeId == null) {
-          continue;
-        }
-        changes.add(changeId);
-      }
-    }
-    return changes;
-  }
-
-  private static <T extends Comment> List<T> sort(List<T> comments) {
+  public static <T extends Comment> List<T> sort(List<T> comments) {
     comments.sort(COMMENT_ORDER);
     return comments;
   }
diff --git a/java/com/google/gerrit/server/DeleteZombieComments.java b/java/com/google/gerrit/server/DeleteZombieComments.java
new file mode 100644
index 0000000..4532b04
--- /dev/null
+++ b/java/com/google/gerrit/server/DeleteZombieComments.java
@@ -0,0 +1,304 @@
+// 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;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+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.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.Change;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import java.io.IOException;
+import java.sql.Timestamp;
+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 java.util.function.Consumer;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * This class can be used to clean zombie draft comments. More context in <a
+ * href="https://gerrit-review.googlesource.com/c/gerrit/+/246233">
+ * https://gerrit-review.googlesource.com/c/gerrit/+/246233 </a>
+ *
+ * <p>The implementation has two cases for detecting zombie drafts:
+ *
+ * <ul>
+ *   <li>An earlier bug in the deletion of draft comments caused some draft refs to remain empty but
+ *       not get deleted.
+ *   <li>Inspecting all draft-comments. 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 DeleteZombieComments#dryRun} mode, the zombie draft IDs will only be logged for
+ *       tracking, otherwise they will also be deleted.
+ * </uL>
+ */
+public abstract class DeleteZombieComments<KeyT> implements AutoCloseable {
+  @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_DeleteZombieComments_ChangeUserIDsPair(changeId, accountId);
+    }
+  }
+
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final int cleanupPercentage;
+  protected final boolean dryRun;
+  @Nullable private final Consumer<String> uiConsumer;
+  @Nullable private final GitRepositoryManager repoManager;
+  @Nullable private final DraftCommentsReader draftCommentsReader;
+  @Nullable private final ChangeNotes.Factory changeNotesFactory;
+  @Nullable private final CommentsUtil commentsUtil;
+
+  private Map<Change.Id, Project.NameKey> changeProjectMap = new HashMap<>();
+  private Map<Change.Id, ChangeNotes> changeNotes = new HashMap<>();
+
+  protected DeleteZombieComments(
+      Integer cleanupPercentage,
+      boolean dryRun,
+      Consumer<String> uiConsumer,
+      GitRepositoryManager repoManager,
+      DraftCommentsReader draftCommentsReader,
+      ChangeNotes.Factory changeNotesFactory,
+      CommentsUtil commentsUtil) {
+    this.cleanupPercentage = cleanupPercentage == null ? 100 : cleanupPercentage;
+    this.dryRun = dryRun;
+    this.uiConsumer = uiConsumer;
+    this.repoManager = repoManager;
+    this.draftCommentsReader = draftCommentsReader;
+    this.changeNotesFactory = changeNotesFactory;
+    this.commentsUtil = commentsUtil;
+  }
+
+  /** Deletes all draft comments. Returns the number of zombie draft comments that were deleted. */
+  @CanIgnoreReturnValue
+  public int execute() throws IOException {
+    setup();
+    ListMultimap<KeyT, HumanComment> alreadyPublished = listDraftCommentsThatAreAlsoPublished();
+    if (!dryRun) {
+      deleteZombieDrafts(alreadyPublished);
+    }
+
+    List<KeyT> emptyDrafts = filterByCleanupPercentage(listEmptyDrafts(), "empty");
+    if (!dryRun) {
+      deleteEmptyDraftsByKey(emptyDrafts);
+    } else {
+      logInfo(
+          String.format(
+              "Running in dry run mode. Skipping deletion."
+                  + "\nStats (with %d cleanup-percentage):"
+                  + "\nEmpty drafts = %d"
+                  + "\nAlready published drafts (zombies) = %d",
+              cleanupPercentage, emptyDrafts.size(), alreadyPublished.size()));
+    }
+    return emptyDrafts.size() + alreadyPublished.size();
+  }
+
+  @VisibleForTesting
+  public abstract void setup() throws IOException;
+
+  @Override
+  public abstract void close() throws IOException;
+
+  protected abstract List<KeyT> listAllDrafts() throws IOException;
+
+  protected abstract List<KeyT> listEmptyDrafts() throws IOException;
+
+  protected abstract void deleteEmptyDraftsByKey(Collection<KeyT> keys) throws IOException;
+
+  protected abstract void deleteZombieDrafts(ListMultimap<KeyT, HumanComment> drafts)
+      throws IOException;
+
+  protected abstract Change.Id getChangeId(KeyT key);
+
+  protected abstract Account.Id getAccountId(KeyT key);
+
+  protected abstract String loggable(KeyT key);
+
+  protected ChangeNotes getChangeNotes(Change.Id changeId) {
+    if (changeNotes.containsKey(changeId)) {
+      return changeNotes.get(changeId);
+    }
+    checkState(
+        changeProjectMap.containsKey(changeId),
+        "Cannot get a project associated with change ID " + changeId);
+    ChangeNotes notes = changeNotesFactory.createChecked(changeProjectMap.get(changeId), changeId);
+    changeNotes.put(changeId, notes);
+    return notes;
+  }
+
+  private List<KeyT> filterByCleanupPercentage(List<KeyT> drafts, String reason) {
+    if (cleanupPercentage >= 100) {
+      logInfo(
+          String.format(
+              "Cleanup percentage = %d" + "\nNumber of drafts to be cleaned for %s = %d",
+              cleanupPercentage, reason, drafts.size()));
+      return drafts;
+    }
+    ImmutableList<KeyT> res =
+        drafts.stream()
+            .filter(key -> getChangeId(key).get() % 100 < cleanupPercentage)
+            .collect(toImmutableList());
+    logInfo(
+        String.format(
+            "Cleanup percentage = %d"
+                + "\nOriginal number of drafts for %s = %d"
+                + "\nNumber of drafts to be processed for %s = %d",
+            cleanupPercentage, reason, drafts.size(), reason, res.size()));
+    return res;
+  }
+
+  @VisibleForTesting
+  public ListMultimap<KeyT, HumanComment> listDraftCommentsThatAreAlsoPublished()
+      throws IOException {
+    List<KeyT> draftKeys = filterByCleanupPercentage(listAllDrafts(), "all-drafts");
+    changeProjectMap.putAll(mapChangesWithDraftsToProjects(draftKeys));
+
+    ListMultimap<KeyT, HumanComment> zombieDrafts = ArrayListMultimap.create();
+    Set<ChangeUserIDsPair> visitedSet = new HashSet<>();
+    for (KeyT key : draftKeys) {
+      try {
+        Change.Id changeId = getChangeId(key);
+        Account.Id accountId = getAccountId(key);
+        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 [%s]",
+              changeId, loggable(key));
+          continue;
+        }
+        List<HumanComment> drafts =
+            draftCommentsReader.getDraftsByChangeAndDraftAuthor(changeId, accountId);
+        ChangeNotes notes = getChangeNotes(changeId);
+        List<HumanComment> published = commentsUtil.publishedHumanCommentsByChange(notes);
+        Set<String> publishedIds = toUuid(published);
+        ImmutableList<HumanComment> zombieDraftsForChangeAndAuthor =
+            drafts.stream()
+                .filter(draft -> publishedIds.contains(draft.key.uuid))
+                .collect(toImmutableList());
+        zombieDraftsForChangeAndAuthor.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));
+        zombieDrafts.putAll(key, zombieDraftsForChangeAndAuthor);
+      } catch (RuntimeException e) {
+        logger.atWarning().withCause(e).log("Failed to process draft [%s]", loggable(key));
+      }
+    }
+
+    if (!zombieDrafts.isEmpty()) {
+      Timestamp earliestZombieTs = null;
+      Timestamp latestZombieTs = null;
+      for (HumanComment zombieDraft : zombieDrafts.values()) {
+        earliestZombieTs = getEarlierTs(earliestZombieTs, zombieDraft.writtenOn);
+        latestZombieTs = getLaterTs(latestZombieTs, zombieDraft.writtenOn);
+      }
+      logger.atWarning().log(
+          "Detected %d zombie drafts that were already published (earliest at %s, latest at %s).",
+          zombieDrafts.size(), earliestZombieTs, latestZombieTs);
+    }
+    return zombieDrafts;
+  }
+
+  /**
+   * 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> mapChangesWithDraftsToProjects(List<KeyT> drafts) {
+    ImmutableSet<Change.Id> changeIds =
+        drafts.stream().map(key -> getChangeId(key)).collect(ImmutableSet.toImmutableSet());
+    Map<Change.Id, Project.NameKey> result = new HashMap<>();
+    for (Project.NameKey project : repoManager.list()) {
+      try (Repository repo = repoManager.openRepository(project)) {
+        Sets.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;
+  }
+
+  protected void logInfo(String message) {
+    logger.atInfo().log("%s", message);
+    uiConsumer.accept(message);
+  }
+
+  /** 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(toImmutableSet());
+  }
+
+  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;
+  }
+}
diff --git a/java/com/google/gerrit/server/DraftCommentsReader.java b/java/com/google/gerrit/server/DraftCommentsReader.java
new file mode 100644
index 0000000..1eea228
--- /dev/null
+++ b/java/com/google/gerrit/server/DraftCommentsReader.java
@@ -0,0 +1,67 @@
+// 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;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+public interface DraftCommentsReader {
+  /**
+   * Returns a single draft of the provided change, that was written by {@code author} and has the
+   * given {@code key}, or {@code Optional::empty} if there is no such comment.
+   */
+  Optional<HumanComment> getDraftComment(ChangeNotes notes, IdentifiedUser author, Comment.Key key);
+
+  /**
+   * Returns all drafts of the provided change, that were written by {@code author}. The comments
+   * are sorted by {@link CommentsUtil#COMMENT_ORDER}.
+   */
+  List<HumanComment> getDraftsByChangeAndDraftAuthor(ChangeNotes notes, Account.Id author);
+
+  /**
+   * Returns all drafts of the provided change, that were written by {@code author}. The comments
+   * are sorted by {@link CommentsUtil#COMMENT_ORDER}.
+   *
+   * <p>If you already have a ChangeNotes instance, consider using {@link
+   * #getDraftsByChangeAndDraftAuthor(ChangeNotes, Account.Id)} instead.
+   */
+  List<HumanComment> getDraftsByChangeAndDraftAuthor(Change.Id changeId, Account.Id author);
+
+  /**
+   * Returns all drafts of the provided patch set, that were written by {@code author}. The comments
+   * are sorted by {@link CommentsUtil#COMMENT_ORDER}.
+   */
+  List<HumanComment> getDraftsByPatchSetAndDraftAuthor(
+      ChangeNotes notes, PatchSet.Id psId, Account.Id author);
+
+  /**
+   * Returns all drafts of the provided change, regardless of the author. The comments are sorted by
+   * {@link CommentsUtil#COMMENT_ORDER}.
+   */
+  List<HumanComment> getDraftsByChangeForAllAuthors(ChangeNotes notes);
+
+  /** Returns all users that have any draft comments on the provided change. */
+  Set<Account.Id> getUsersWithDrafts(ChangeNotes changeNotes);
+
+  /** Returns all changes that contain draft comments of {@code author}. */
+  Set<Change.Id> getChangesWithDrafts(Account.Id author);
+}
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index 36d7888..d45d329 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -468,9 +468,7 @@
 
   public PersonIdent newCommitterIdent(Instant when, ZoneId zoneId) {
     final Account ua = getAccount();
-    String name = ua.fullName();
     String email = ua.preferredEmail();
-
     if (email == null || email.isEmpty()) {
       // No preferred email is configured. Use a generic identity so we
       // don't leak an address the user may have given us, but doesn't
@@ -491,19 +489,18 @@
 
       email = user + "@" + host;
     }
-
-    if (name == null || name.isEmpty()) {
-      final int at = email.indexOf('@');
-      if (0 < at) {
-        name = email.substring(0, at);
-      } else {
-        name = anonymousCowardName;
-      }
-    }
-
+    String name = getCommitterName(ua, email);
     return new PersonIdent(name, email, when, zoneId);
   }
 
+  public Optional<PersonIdent> newCommitterIdent(String email, Instant when, ZoneId zoneId) {
+    if (!hasEmailAddress(email)) {
+      return Optional.empty();
+    }
+    String name = getCommitterName(getAccount(), email);
+    return Optional.of(new PersonIdent(name, email, when, zoneId));
+  }
+
   @Override
   public String toString() {
     return "IdentifiedUser[account " + getAccountId() + "]";
@@ -551,4 +548,17 @@
   public boolean hasSameAccountId(CurrentUser other) {
     return getAccountId().get() == other.getAccountId().get();
   }
+
+  protected String getCommitterName(Account ua, String email) {
+    String name = ua.fullName();
+    if (name == null || name.isEmpty()) {
+      final int at = email.indexOf('@');
+      if (0 < at) {
+        name = email.substring(0, at);
+      } else {
+        name = anonymousCowardName;
+      }
+    }
+    return name;
+  }
 }
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index 68d2314..81d8f8f 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelFunction;
@@ -53,6 +54,8 @@
 /** Utilities for manipulating patch sets. */
 @Singleton
 public class PatchSetUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final Provider<ApprovalsUtil> approvalsUtilProvider;
   private final ProjectCache projectCache;
   private final GitRepositoryManager repoManager;
@@ -76,7 +79,22 @@
   }
 
   public ImmutableCollection<PatchSet> byChange(ChangeNotes notes) {
-    return notes.load().getPatchSets().values();
+    ChangeNotes reloadedNotes = notes.load();
+
+    if (!reloadedNotes
+        .getPatchSets()
+        .keySet()
+        .contains(reloadedNotes.getChange().currentPatchSetId())) {
+      logger.atSevere().log(
+          "Current patch set %s missing in ChangeNotes of change %s (available patch sets: %s,"
+              + " meta revision: %s)",
+          reloadedNotes.getChange().currentPatchSetId().get(),
+          reloadedNotes.getChange().getId().get(),
+          reloadedNotes.getPatchSets().keySet(),
+          reloadedNotes.getRevision().name());
+    }
+
+    return reloadedNotes.getPatchSets().values();
   }
 
   public ImmutableMap<PatchSet.Id, PatchSet> byChangeAsMap(ChangeNotes notes) {
diff --git a/java/com/google/gerrit/server/PerformanceMetrics.java b/java/com/google/gerrit/server/PerformanceMetrics.java
deleted file mode 100644
index 845ed80..0000000
--- a/java/com/google/gerrit/server/PerformanceMetrics.java
+++ /dev/null
@@ -1,91 +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;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.metrics.Counter3;
-import com.google.gerrit.metrics.Description;
-import com.google.gerrit.metrics.Field;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.metrics.Timer3;
-import com.google.gerrit.server.logging.Metadata;
-import com.google.gerrit.server.logging.PerformanceLogger;
-import com.google.gerrit.server.logging.TraceContext;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.concurrent.TimeUnit;
-
-/** Performance logger that records the execution times as a metric. */
-@Singleton
-public class PerformanceMetrics implements PerformanceLogger {
-  private static final String OPERATION_LATENCY_METRIC_NAME = "performance/operations";
-  private static final String OPERATION_COUNT_METRIC_NAME = "performance/operations_count";
-
-  public final Timer3<String, String, String> operationsLatency;
-  public final Counter3<String, String, String> operationsCounter;
-
-  @Inject
-  PerformanceMetrics(MetricMaker metricMaker) {
-    Field<String> operationNameField =
-        Field.ofString(
-                "operation_name",
-                (metadataBuilder, fieldValue) -> metadataBuilder.operationName(fieldValue))
-            .description("The operation that was performed.")
-            .build();
-    Field<String> requestField =
-        Field.ofString("request", (metadataBuilder, fieldValue) -> {})
-            .description(
-                "The request for which the operation was performed"
-                    + " (format = '<request-type> <redacted-request-uri>').")
-            .build();
-    Field<String> pluginField =
-        Field.ofString(
-                "plugin", (metadataBuilder, fieldValue) -> metadataBuilder.pluginName(fieldValue))
-            .description("The name of the plugin that performed the operation.")
-            .build();
-
-    this.operationsLatency =
-        metricMaker
-            .newTimer(
-                OPERATION_LATENCY_METRIC_NAME,
-                new Description("Latency of performing operations")
-                    .setCumulative()
-                    .setUnit(Description.Units.MILLISECONDS),
-                operationNameField,
-                requestField,
-                pluginField)
-            .suppressLogging();
-    this.operationsCounter =
-        metricMaker.newCounter(
-            OPERATION_COUNT_METRIC_NAME,
-            new Description("Number of performed operations").setRate(),
-            operationNameField,
-            requestField,
-            pluginField);
-  }
-
-  @Override
-  public void log(String operation, long durationMs) {
-    log(operation, durationMs, /* metadata= */ null);
-  }
-
-  @Override
-  public void log(String operation, long durationMs, @Nullable Metadata metadata) {
-    String requestTag = TraceContext.getTag(TraceRequestListener.TAG_REQUEST).orElse("");
-    String pluginTag = TraceContext.getPluginTag().orElse("");
-    operationsLatency.record(operation, requestTag, pluginTag, durationMs, TimeUnit.MILLISECONDS);
-    operationsCounter.increment(operation, requestTag, pluginTag);
-  }
-}
diff --git a/java/com/google/gerrit/server/PublishCommentsOp.java b/java/com/google/gerrit/server/PublishCommentsOp.java
index 7dcad7c..8205907 100644
--- a/java/com/google/gerrit/server/PublishCommentsOp.java
+++ b/java/com/google/gerrit/server/PublishCommentsOp.java
@@ -48,7 +48,7 @@
   private final PatchSetUtil psUtil;
   private final ChangeNotes.Factory changeNotesFactory;
   private final CommentAdded commentAdded;
-  private final CommentsUtil commentsUtil;
+  private final DraftCommentsReader draftCommentsReader;
   private final EmailReviewComments.Factory email;
   private final Project.NameKey projectNameKey;
   private final PatchSet.Id psId;
@@ -67,7 +67,7 @@
   public PublishCommentsOp(
       ChangeNotes.Factory changeNotesFactory,
       CommentAdded commentAdded,
-      CommentsUtil commentsUtil,
+      DraftCommentsReader draftCommentsReader,
       EmailReviewComments.Factory email,
       PatchSetUtil psUtil,
       PublishCommentUtil publishCommentUtil,
@@ -76,7 +76,7 @@
       @Assisted Project.NameKey projectNameKey) {
     this.changeNotesFactory = changeNotesFactory;
     this.commentAdded = commentAdded;
-    this.commentsUtil = commentsUtil;
+    this.draftCommentsReader = draftCommentsReader;
     this.email = email;
     this.psId = psId;
     this.publishCommentUtil = publishCommentUtil;
@@ -93,7 +93,9 @@
           PatchListNotAvailableException,
           CommentsRejectedException {
     preUpdateMetaId = ctx.getNotes().getMetaId();
-    comments = commentsUtil.draftByChangeAuthor(ctx.getNotes(), ctx.getUser().getAccountId());
+    comments =
+        draftCommentsReader.getDraftsByChangeAndDraftAuthor(
+            ctx.getNotes(), ctx.getUser().getAccountId());
 
     // PublishCommentsOp should update a separate ChangeUpdate Object than the one used by other ops
     // For example, with the "publish comments on PS upload" workflow,
diff --git a/java/com/google/gerrit/server/RequestConfig.java b/java/com/google/gerrit/server/RequestConfig.java
index 83cea5b..f942c5e 100644
--- a/java/com/google/gerrit/server/RequestConfig.java
+++ b/java/com/google/gerrit/server/RequestConfig.java
@@ -42,6 +42,8 @@
         requestConfig.requestTypes(parseRequestTypes(cfg, section, id));
         requestConfig.requestUriPatterns(parseRequestUriPatterns(cfg, section, id));
         requestConfig.excludedRequestUriPatterns(parseExcludedRequestUriPatterns(cfg, section, id));
+        requestConfig.requestQueryStringPatterns(parseRequestQueryStringPatterns(cfg, section, id));
+        requestConfig.headerPatterns(parseHeaderPatterns(cfg, section, id));
         requestConfig.accountIds(parseAccounts(cfg, section, id));
         requestConfig.projectPatterns(parseProjectPatterns(cfg, section, id));
         requestConfigs.add(requestConfig.build());
@@ -67,6 +69,16 @@
     return parsePatterns(cfg, section, id, "excludedRequestUriPattern");
   }
 
+  private static ImmutableSet<Pattern> parseRequestQueryStringPatterns(
+      Config cfg, String section, String id) throws ConfigInvalidException {
+    return parsePatterns(cfg, section, id, "requestQueryStringPattern");
+  }
+
+  private static ImmutableSet<Pattern> parseHeaderPatterns(Config cfg, String section, String id)
+      throws ConfigInvalidException {
+    return parsePatterns(cfg, section, id, "headerPattern");
+  }
+
   private static ImmutableSet<Account.Id> parseAccounts(Config cfg, String section, String id)
       throws ConfigInvalidException {
     ImmutableSet.Builder<Account.Id> accountIds = ImmutableSet.builder();
@@ -124,6 +136,12 @@
   /** pattern matching request URIs to be excluded */
   abstract ImmutableSet<Pattern> excludedRequestUriPatterns();
 
+  /** pattern matching request query strings */
+  abstract ImmutableSet<Pattern> requestQueryStringPatterns();
+
+  /** pattern matching headers */
+  abstract ImmutableSet<Pattern> headerPatterns();
+
   /** accounts IDs matching calling user */
   abstract ImmutableSet<Account.Id> accountIds();
 
@@ -170,6 +188,37 @@
       return false;
     }
 
+    // If in the request config request query string patterns are set and none of them matches,
+    // then the request is not matched.
+    if (!requestQueryStringPatterns().isEmpty()) {
+      if (!requestInfo.requestQueryString().isPresent()) {
+        // The request has no request query string, hence it cannot match a request query string
+        // pattern.
+        return false;
+      }
+
+      if (requestQueryStringPatterns().stream()
+          .noneMatch(p -> p.matcher(requestInfo.requestQueryString().get()).matches())) {
+        return false;
+      }
+    }
+
+    // If in the request config header patterns are set and none of them matches, then the request
+    // is not matched.
+    if (!headerPatterns().isEmpty()) {
+      if (requestInfo.headers().isEmpty()) {
+        // The request has no headers, hence it cannot match a header pattern.
+        return false;
+      }
+
+      if (headerPatterns().stream()
+          .noneMatch(
+              p ->
+                  requestInfo.headers().stream().anyMatch(header -> p.matcher(header).matches()))) {
+        return false;
+      }
+    }
+
     // If in the request config accounts are set and none of them matches, then the request is not
     // matched.
     if (!accountIds().isEmpty()) {
@@ -198,9 +247,9 @@
       }
     }
 
-    // For any match criteria (request type, request URI pattern, account, project pattern) that
-    // was specified in the request config, at least one of the configured value matched the
-    // request.
+    // For any match criteria (request type, request URI pattern, request query string pattern,
+    // header, account, project pattern) that was specified in the request config, at least one of
+    // the configured value matched the request.
     return true;
   }
 
@@ -218,6 +267,10 @@
 
     abstract Builder excludedRequestUriPatterns(ImmutableSet<Pattern> excludedRequestUriPatterns);
 
+    abstract Builder requestQueryStringPatterns(ImmutableSet<Pattern> requestQueryStringPatterns);
+
+    abstract Builder headerPatterns(ImmutableSet<Pattern> headerPatterns);
+
     abstract Builder accountIds(ImmutableSet<Account.Id> accountIds);
 
     abstract Builder projectPatterns(ImmutableSet<Pattern> projectPatterns);
diff --git a/java/com/google/gerrit/server/RequestInfo.java b/java/com/google/gerrit/server/RequestInfo.java
index 791e228..927985d8 100644
--- a/java/com/google/gerrit/server/RequestInfo.java
+++ b/java/com/google/gerrit/server/RequestInfo.java
@@ -20,6 +20,8 @@
 import com.google.auto.value.AutoValue;
 import com.google.auto.value.extension.memoized.Memoized;
 import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.logging.TraceContext;
@@ -57,10 +59,23 @@
    * <p>Only set if request type is {@link RequestType#REST}.
    *
    * <p>Never includes the "/a" prefix.
+   *
+   * <p>Doesn't include the query string with the request parameters (see {@link
+   * #requestQueryString()}.
    */
   public abstract Optional<String> requestUri();
 
   /**
+   * Request query string that contains the request parameters.
+   *
+   * <p>Only set if request type is {@link RequestType#REST}.
+   */
+  public abstract Optional<String> requestQueryString();
+
+  /** Request headers in the form '{@code <header-name>:<header-value>}'. */
+  public abstract ImmutableList<String> headers();
+
+  /**
    * Redacted request URI.
    *
    * <p>Request URI where resource IDs are replaced by '*'.
@@ -164,6 +179,18 @@
 
     public abstract Builder requestUri(String requestUri);
 
+    public abstract Builder requestQueryString(String requestQueryString);
+
+    /** Gets a builder for adding reasons for this status. */
+    abstract ImmutableList.Builder<String> headersBuilder();
+
+    /** Adds a header. */
+    @CanIgnoreReturnValue
+    public Builder addHeader(String headerName, String headerValue) {
+      headersBuilder().add(headerName + "=" + headerValue);
+      return this;
+    }
+
     public abstract Builder callingUser(CurrentUser callingUser);
 
     public abstract Builder traceContext(TraceContext traceContext);
diff --git a/java/com/google/gerrit/server/Sequence.java b/java/com/google/gerrit/server/Sequence.java
new file mode 100644
index 0000000..844b583
--- /dev/null
+++ b/java/com/google/gerrit/server/Sequence.java
@@ -0,0 +1,82 @@
+// 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;
+
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.common.collect.ImmutableList;
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * An incrementing sequence that's used to assign new unique numbers for change, account and group
+ * IDs.
+ */
+public interface Sequence {
+  String NAME_ACCOUNTS = "accounts";
+  String NAME_GROUPS = "groups";
+  String NAME_CHANGES = "changes";
+
+  /**
+   * Some callers cannot get the normal {@link #NAME_ACCOUNTS} sequence injected because some
+   * injected fields are not available at injection time. Allow for providing a light-weight
+   * injected instance.
+   */
+  @BindingAnnotation
+  @Target({FIELD, PARAMETER, METHOD})
+  @Retention(RUNTIME)
+  @interface LightweightAccounts {}
+
+  /**
+   * Some callers cannot get the normal {@link #NAME_GROUPS} sequence injected because some injected
+   * fields are not available at injection time. Allow for providing a light-weight injected
+   * instance.
+   */
+  @BindingAnnotation
+  @Target({FIELD, PARAMETER, METHOD})
+  @Retention(RUNTIME)
+  @interface LightweightGroups {}
+
+  enum SequenceType {
+    ACCOUNTS,
+    CHANGES,
+    GROUPS;
+  }
+
+  /** Returns the next available sequence value and increments the sequence for the next call. */
+  int next();
+
+  /** Similar to {@link #next()} but returns a {@code count} of next available values. */
+  ImmutableList<Integer> next(int count);
+
+  /** Returns the next available sequence value. */
+  int current();
+
+  /** Returns the last sequence number that was assigned. */
+  int last();
+
+  /**
+   * Stores a new {@code value} to be returned on the next calls for {@link #next()} or {@link
+   * #current()}.
+   */
+  void storeNew(int value);
+
+  /** Returns the batch size that was used to initialize the sequence. */
+  int getBatchSize();
+}
diff --git a/java/com/google/gerrit/server/Sequences.java b/java/com/google/gerrit/server/Sequences.java
new file mode 100644
index 0000000..431a1b2
--- /dev/null
+++ b/java/com/google/gerrit/server/Sequences.java
@@ -0,0 +1,143 @@
+// 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;
+
+import static com.google.gerrit.server.Sequence.NAME_ACCOUNTS;
+import static com.google.gerrit.server.Sequence.NAME_CHANGES;
+import static com.google.gerrit.server.Sequence.NAME_GROUPS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer2;
+import com.google.gerrit.server.Sequence.SequenceType;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+
+@Singleton
+public class Sequences {
+  public static final int FIRST_CHANGE_ID = 1;
+  public static final int FIRST_GROUP_ID = 1;
+  public static final int FIRST_ACCOUNT_ID = 1000000;
+
+  private final Sequence accountSeq;
+  private final Sequence changeSeq;
+  private final Sequence groupSeq;
+  private final Timer2<SequenceType, Boolean> nextIdLatency;
+
+  @Inject
+  public Sequences(
+      MetricMaker metrics,
+      @Named(NAME_ACCOUNTS) Sequence accountsSeq,
+      @Named(NAME_GROUPS) Sequence groupsSeq,
+      @Named(NAME_CHANGES) Sequence changesSeq) {
+    this.accountSeq = accountsSeq;
+    this.groupSeq = groupsSeq;
+    this.changeSeq = changesSeq;
+
+    nextIdLatency =
+        metrics.newTimer(
+            "sequence/next_id_latency",
+            new Description("Latency of requesting IDs from repo sequences")
+                .setCumulative()
+                .setUnit(Units.MILLISECONDS),
+            Field.ofEnum(SequenceType.class, "sequence", Metadata.Builder::noteDbSequenceType)
+                .description("The sequence from which IDs were retrieved.")
+                .build(),
+            Field.ofBoolean("multiple", Metadata.Builder::multiple)
+                .description("Whether more than one ID was retrieved.")
+                .build());
+  }
+
+  public int nextAccountId() {
+    try (Timer2.Context<SequenceType, Boolean> timer =
+        nextIdLatency.start(Sequence.SequenceType.ACCOUNTS, false)) {
+      return accountSeq.next();
+    }
+  }
+
+  public int nextChangeId() {
+    try (Timer2.Context<SequenceType, Boolean> timer =
+        nextIdLatency.start(Sequence.SequenceType.CHANGES, false)) {
+      return changeSeq.next();
+    }
+  }
+
+  public ImmutableList<Integer> nextChangeIds(int count) {
+    try (Timer2.Context<SequenceType, Boolean> timer =
+        nextIdLatency.start(Sequence.SequenceType.CHANGES, count > 1)) {
+      return changeSeq.next(count);
+    }
+  }
+
+  public int nextGroupId() {
+    try (Timer2.Context<SequenceType, Boolean> timer =
+        nextIdLatency.start(Sequence.SequenceType.GROUPS, false)) {
+      return groupSeq.next();
+    }
+  }
+
+  public int changeBatchSize() {
+    return changeSeq.getBatchSize();
+  }
+
+  public int groupBatchSize() {
+    return groupSeq.getBatchSize();
+  }
+
+  public int accountBatchSize() {
+    return accountSeq.getBatchSize();
+  }
+
+  public int currentChangeId() {
+    return changeSeq.current();
+  }
+
+  public int currentAccountId() {
+    return accountSeq.current();
+  }
+
+  public int currentGroupId() {
+    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);
+  }
+
+  public void setAccountIdValue(int value) {
+    accountSeq.storeNew(value);
+  }
+
+  public void setGroupIdValue(int value) {
+    groupSeq.storeNew(value);
+  }
+}
diff --git a/java/com/google/gerrit/server/StarredChangesReader.java b/java/com/google/gerrit/server/StarredChangesReader.java
new file mode 100644
index 0000000..ddf0cd3
--- /dev/null
+++ b/java/com/google/gerrit/server/StarredChangesReader.java
@@ -0,0 +1,40 @@
+// 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;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+public interface StarredChangesReader {
+  boolean isStarred(Account.Id accountId, Change.Id changeId);
+
+  /**
+   * Returns a subset of change IDs among the input {@code changeIds} list that are starred by the
+   * {@code caller} user.
+   */
+  Set<Change.Id> areStarred(Repository allUsersRepo, List<Change.Id> changeIds, Account.Id caller);
+
+  ImmutableMap<Account.Id, Ref> byChange(Change.Id changeId);
+
+  ImmutableSet<Change.Id> byAccountId(Account.Id accountId);
+
+  ImmutableSet<Change.Id> byAccountId(Account.Id accountId, boolean skipInvalidChanges);
+}
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index e6d4144..0709b86 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -14,474 +14,6 @@
 
 package com.google.gerrit.server;
 
-import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.Objects.requireNonNull;
-import static java.util.stream.Collectors.joining;
-import static java.util.stream.Collectors.toSet;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.base.CharMatcher;
-import com.google.common.base.Joiner;
-import com.google.common.base.Splitter;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ImmutableSortedSet;
-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.Change;
-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.config.AllUsersName;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-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.update.context.RefUpdateContext;
-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.Collections;
-import java.util.List;
-import java.util.NavigableSet;
-import java.util.Objects;
-import java.util.Set;
-import java.util.TreeSet;
-import java.util.stream.Collectors;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectLoader;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
-@Singleton
-public class StarredChangesUtil {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  @AutoValue
-  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) {
-        Integer id = Ints.tryParse(s.substring(0, p));
-        if (id == null) {
-          return null;
-        }
-        Account.Id accountId = Account.id(id);
-        String label = s.substring(p + 1);
-        return create(accountId, label);
-      }
-      return null;
-    }
-
-    public static StarField create(Account.Id accountId, String label) {
-      return new AutoValue_StarredChangesUtil_StarField(accountId, label);
-    }
-
-    public abstract Account.Id accountId();
-
-    public abstract String label();
-
-    @Override
-    public final String toString() {
-      return accountId() + SEPARATOR + label();
-    }
-  }
-
-  public enum Operation {
-    ADD,
-    REMOVE
-  }
-
-  @AutoValue
-  public abstract static class StarRef {
-    private static final StarRef MISSING =
-        new AutoValue_StarredChangesUtil_StarRef(null, Collections.emptyNavigableSet());
-
-    private static StarRef create(Ref ref, Iterable<String> labels) {
-      return new AutoValue_StarredChangesUtil_StarRef(
-          requireNonNull(ref), ImmutableSortedSet.copyOf(labels));
-    }
-
-    @Nullable
-    public abstract Ref ref();
-
-    public abstract NavigableSet<String> labels();
-
-    public ObjectId objectId() {
-      return ref() != null ? ref().getObjectId() : ObjectId.zeroId();
-    }
-  }
-
-  public static class IllegalLabelException extends Exception {
-    private static final long serialVersionUID = 1L;
-
-    IllegalLabelException(String message) {
-      super(message);
-    }
-  }
-
-  public static class InvalidLabelsException extends IllegalLabelException {
-    private static final long serialVersionUID = 1L;
-
-    InvalidLabelsException(Set<String> invalidLabels) {
-      super(String.format("invalid labels: %s", Joiner.on(", ").join(invalidLabels)));
-    }
-  }
-
-  public static class MutuallyExclusiveLabelsException extends IllegalLabelException {
-    private static final long serialVersionUID = 1L;
-
-    MutuallyExclusiveLabelsException(String label1, String label2) {
-      super(
-          String.format(
-              "The labels %s and %s are mutually exclusive. Only one of them can be set.",
-              label1, label2));
-    }
-  }
-
-  public static final String DEFAULT_LABEL = "star";
-
-  private final GitRepositoryManager repoManager;
-  private final GitReferenceUpdated gitRefUpdated;
-  private final AllUsersName allUsers;
-  private final Provider<PersonIdent> serverIdent;
-
-  @Inject
-  StarredChangesUtil(
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated gitRefUpdated,
-      AllUsersName allUsers,
-      @GerritPersonIdent Provider<PersonIdent> serverIdent) {
-    this.repoManager = repoManager;
-    this.gitRefUpdated = gitRefUpdated;
-    this.allUsers = allUsers;
-    this.serverIdent = serverIdent;
-  }
-
-  public NavigableSet<String> getLabels(Account.Id accountId, Change.Id virtualId) {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      return readLabels(repo, RefNames.refsStarredChanges(virtualId, accountId)).labels();
-    } catch (IOException e) {
-      throw new StorageException(
-          String.format(
-              "Reading stars from change %d for account %d failed",
-              virtualId.get(), accountId.get()),
-          e);
-    }
-  }
-
-  public void star(Account.Id accountId, Change.Id virtualId, Operation op)
-      throws IllegalLabelException {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      String refName = RefNames.refsStarredChanges(virtualId, accountId);
-      StarRef old = readLabels(repo, refName);
-
-      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 {
-        updateLabels(repo, refName, old.objectId(), labels);
-      }
-    } catch (IOException e) {
-      throw new StorageException(
-          String.format("Star change %d for account %d failed", virtualId.get(), accountId.get()),
-          e);
-    }
-  }
-
-  /**
-   * Returns a subset of change IDs among the input {@code virtualIds} list that are starred by the
-   * {@code caller} user.
-   */
-  public Set<Change.Id> areStarred(
-      Repository allUsersRepo, List<Change.Id> virtualIds, Account.Id caller) {
-    List<String> starRefs =
-        virtualIds.stream()
-            .map(c -> RefNames.refsStarredChanges(c, caller))
-            .collect(Collectors.toList());
-    try {
-      return allUsersRepo
-          .getRefDatabase()
-          .exactRef(starRefs.toArray(new String[0]))
-          .keySet()
-          .stream()
-          .map(r -> Change.Id.fromAllUsersRef(r))
-          .collect(Collectors.toSet());
-    } catch (IOException e) {
-      logger.atWarning().withCause(e).log(
-          "Failed getting starred changes for account %d within changes: %s",
-          caller.get(), Joiner.on(", ").join(virtualIds));
-      return ImmutableSet.of();
-    }
-  }
-
-  /**
-   * Unstar the given change for all users.
-   *
-   * <p>Intended for use only when we're about to delete a change. For that reason, the change is
-   * not reindexed.
-   *
-   * @param virtualId change ID.
-   * @throws IOException if an error occurred.
-   */
-  public void unstarAllForChangeDeletion(Change.Id virtualId) throws IOException {
-    try (Repository repo = repoManager.openRepository(allUsers);
-        RevWalk rw = new RevWalk(repo)) {
-      BatchRefUpdate batchUpdate = repo.getRefDatabase().newBatchUpdate();
-      batchUpdate.setAllowNonFastForwards(true);
-      batchUpdate.setRefLogIdent(serverIdent.get());
-      batchUpdate.setRefLogMessage("Unstar change " + virtualId.get(), true);
-      for (Account.Id accountId : getStars(repo, virtualId)) {
-        String refName = RefNames.refsStarredChanges(virtualId, accountId);
-        Ref ref = repo.getRefDatabase().exactRef(refName);
-        if (ref != null) {
-          batchUpdate.addCommand(new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), refName));
-        }
-      }
-      batchUpdate.execute(rw, NullProgressMonitor.INSTANCE);
-      for (ReceiveCommand command : batchUpdate.getCommands()) {
-        if (command.getResult() != ReceiveCommand.Result.OK) {
-          String message =
-              String.format(
-                  "Unstar change %d failed, ref %s could not be deleted: %s",
-                  virtualId.get(), command.getRefName(), command.getResult());
-          if (command.getResult() == ReceiveCommand.Result.LOCK_FAILURE) {
-            throw new LockFailureException(message, batchUpdate);
-          }
-          throw new GitUpdateFailureException(message, batchUpdate);
-        }
-      }
-    }
-  }
-
-  public ImmutableMap<Account.Id, StarRef> byChange(Change.Id virtualId) {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      ImmutableMap.Builder<Account.Id, StarRef> builder = ImmutableMap.builder();
-      for (Account.Id accountId : getStars(repo, virtualId)) {
-        builder.put(accountId, readLabels(repo, RefNames.refsStarredChanges(virtualId, accountId)));
-      }
-      return builder.build();
-    } catch (IOException e) {
-      throw new StorageException(
-          String.format("Get accounts that starred change %d failed", virtualId.get()), e);
-    }
-  }
-
-  public ImmutableSet<Change.Id> byAccountId(Account.Id accountId) {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      ImmutableSet.Builder<Change.Id> builder = ImmutableSet.builder();
-      for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_STARRED_CHANGES)) {
-        Account.Id currentAccountId = Account.Id.fromRef(ref.getName());
-        // Skip all refs that don't correspond with accountId.
-        if (currentAccountId == null || !currentAccountId.equals(accountId)) {
-          continue;
-        }
-        // Skip all refs that don't contain the required label.
-        StarRef starRef = readLabels(repo, ref.getName());
-        if (!starRef.labels().contains(DEFAULT_LABEL)) {
-          continue;
-        }
-
-        // Skip invalid change ids.
-        Change.Id changeId = Change.Id.fromAllUsersRef(ref.getName());
-        if (changeId == null) {
-          continue;
-        }
-        builder.add(changeId);
-      }
-      return builder.build();
-    } catch (IOException e) {
-      throw new StorageException(
-          String.format("Get starred changes for account %d failed", accountId.get()), e);
-    }
-  }
-
-  private static Set<Account.Id> getStars(Repository allUsers, Change.Id virtualId)
-      throws IOException {
-    String prefix = RefNames.refsStarredChangesPrefix(virtualId);
-    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());
-  }
-
-  public ObjectId getObjectId(Account.Id accountId, Change.Id virtualId) {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      Ref ref = repo.exactRef(RefNames.refsStarredChanges(virtualId, accountId));
-      return ref != null ? ref.getObjectId() : ObjectId.zeroId();
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log(
-          "Getting star object ID for account %d on change %d failed",
-          accountId.get(), virtualId.get());
-      return ObjectId.zeroId();
-    }
-  }
-
-  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);
-      return readLabels(repo, ref);
-    }
-  }
-
-  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)));
-    }
-  }
-
-  public static ObjectId writeLabels(Repository repo, Collection<String> labels)
-      throws IOException, InvalidLabelsException {
-    validateLabels(labels);
-    try (ObjectInserter oi = repo.newObjectInserter()) {
-      ObjectId id =
-          oi.insert(
-              Constants.OBJ_BLOB,
-              labels.stream().sorted().distinct().collect(joining("\n")).getBytes(UTF_8));
-      oi.flush();
-      return id;
-    }
-  }
-
-  private static void validateLabels(Collection<String> labels) throws InvalidLabelsException {
-    if (labels == null) {
-      return;
-    }
-
-    NavigableSet<String> invalidLabels = new TreeSet<>();
-    for (String label : labels) {
-      if (CharMatcher.whitespace().matchesAnyOf(label)) {
-        invalidLabels.add(label);
-      }
-    }
-    if (!invalidLabels.isEmpty()) {
-      throw new InvalidLabelsException(invalidLabels);
-    }
-  }
-
-  private void updateLabels(
-      Repository repo, String refName, ObjectId oldObjectId, Collection<String> labels)
-      throws IOException, InvalidLabelsException {
-    try (TraceTimer traceTimer =
-            TraceContext.newTimer(
-                "Update star labels",
-                Metadata.builder().noteDbRefName(refName).resourceCount(labels.size()).build());
-        RevWalk rw = new RevWalk(repo)) {
-      RefUpdate u = repo.updateRef(refName);
-      u.setExpectedOldObjectId(oldObjectId);
-      u.setForceUpdate(true);
-      u.setNewObjectId(writeLabels(repo, labels));
-      u.setRefLogIdent(serverIdent.get());
-      u.setRefLogMessage("Update star labels", true);
-      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
-        RefUpdate.Result result = u.update(rw);
-        switch (result) {
-          case NEW:
-          case FORCED:
-          case NO_CHANGE:
-          case FAST_FORWARD:
-            gitRefUpdated.fire(allUsers, u, null);
-            return;
-          case LOCK_FAILURE:
-            throw new LockFailureException(
-                String.format("Update star labels on ref %s failed", refName), u);
-          case IO_FAILURE:
-          case NOT_ATTEMPTED:
-          case REJECTED:
-          case REJECTED_CURRENT_BRANCH:
-          case RENAMED:
-          case REJECTED_MISSING_OBJECT:
-          case REJECTED_OTHER_REASON:
-          default:
-            throw new StorageException(
-                String.format("Update star labels on ref %s failed: %s", refName, result.name()));
-        }
-      }
-    }
-  }
-
-  private void deleteRef(Repository repo, String refName, ObjectId oldObjectId) throws IOException {
-    if (ObjectId.zeroId().equals(oldObjectId)) {
-      // ref doesn't exist
-      return;
-    }
-
-    try (TraceTimer traceTimer =
-        TraceContext.newTimer(
-            "Delete star labels", Metadata.builder().noteDbRefName(refName).build())) {
-      RefUpdate u = repo.updateRef(refName);
-      u.setForceUpdate(true);
-      u.setExpectedOldObjectId(oldObjectId);
-      u.setRefLogIdent(serverIdent.get());
-      u.setRefLogMessage("Unstar change", true);
-      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
-        RefUpdate.Result result = u.delete();
-        switch (result) {
-          case FORCED:
-            gitRefUpdated.fire(allUsers, u, null);
-            return;
-          case LOCK_FAILURE:
-            throw new LockFailureException(String.format("Delete star ref %s failed", refName), u);
-          case NEW:
-          case NO_CHANGE:
-          case FAST_FORWARD:
-          case IO_FAILURE:
-          case NOT_ATTEMPTED:
-          case REJECTED:
-          case REJECTED_CURRENT_BRANCH:
-          case RENAMED:
-          case REJECTED_MISSING_OBJECT:
-          case REJECTED_OTHER_REASON:
-          default:
-            throw new StorageException(
-                String.format("Delete star ref %s failed: %s", refName, result.name()));
-        }
-      }
-    }
-  }
-}
+// TODO - delete this class. It cannot be deleted as part of the original refactoring since it's
+// used by a plugin.
+public interface StarredChangesUtil extends StarredChangesReader, StarredChangesWriter {}
diff --git a/java/com/google/gerrit/server/StarredChangesWriter.java b/java/com/google/gerrit/server/StarredChangesWriter.java
new file mode 100644
index 0000000..6c14cc9
--- /dev/null
+++ b/java/com/google/gerrit/server/StarredChangesWriter.java
@@ -0,0 +1,36 @@
+// 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;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import java.io.IOException;
+
+public interface StarredChangesWriter {
+  void star(Account.Id accountId, Change.Id changeId);
+
+  void unstar(Account.Id accountId, Change.Id changeId);
+
+  /**
+   * Unstar the given change for all users.
+   *
+   * <p>Intended for use only when we're about to delete a change. For that reason, the change is
+   * not reindexed.
+   *
+   * @param changeId change ID.
+   * @throws IOException if an error occurred.
+   */
+  void unstarAllForChangeDeletion(Change.Id changeId) throws IOException;
+}
diff --git a/java/com/google/gerrit/server/WebLinks.java b/java/com/google/gerrit/server/WebLinks.java
index 58396f5..4676be3 100644
--- a/java/com/google/gerrit/server/WebLinks.java
+++ b/java/com/google/gerrit/server/WebLinks.java
@@ -87,18 +87,20 @@
    * @param commitMessage the commit message of the commit.
    * @param branchName branch of the commit.
    * @param changeKey change Identifier for this change
+   * @param changeId the numeric changeID for this change
    */
   public ImmutableList<WebLinkInfo> getPatchSetLinks(
       Project.NameKey project,
       String commit,
       String commitMessage,
       String branchName,
-      String changeKey) {
+      String changeKey,
+      int changeId) {
     return filterLinks(
         patchSetLinks,
         webLink ->
             webLink.getPatchSetWebLink(
-                project.get(), commit, commitMessage, branchName, changeKey));
+                project.get(), commit, commitMessage, branchName, changeKey, changeId));
   }
 
   /**
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index d306ad0..d7d4938 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account;
 
+import static com.google.gerrit.server.account.AccountCacheImpl.AccountCacheModule.ACCOUNT_CACHE_MODULE;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.cache.CacheLoader;
@@ -24,6 +25,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.ModuleImpl;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.cache.CacheModule;
@@ -52,24 +54,29 @@
 /** Caches important (but small) account state to avoid database hits. */
 @Singleton
 public class AccountCacheImpl implements AccountCache {
+  @ModuleImpl(name = ACCOUNT_CACHE_MODULE)
+  public static class AccountCacheModule extends CacheModule {
+    public static final String ACCOUNT_CACHE_MODULE = "account-cache-module";
+
+    @Override
+    protected void configure() {
+      persist(BYID_AND_REV_NAME, CachedAccountDetails.Key.class, CachedAccountDetails.class)
+          .version(2)
+          .keySerializer(CachedAccountDetails.Key.Serializer.INSTANCE)
+          .valueSerializer(CachedAccountDetails.Serializer.INSTANCE)
+          .loader(Loader.class);
+
+      bind(AccountCacheImpl.class);
+      bind(AccountCache.class).to(AccountCacheImpl.class);
+    }
+  }
+
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private static final String BYID_AND_REV_NAME = "accounts";
 
   public static Module module() {
-    return new CacheModule() {
-      @Override
-      protected void configure() {
-        persist(BYID_AND_REV_NAME, CachedAccountDetails.Key.class, CachedAccountDetails.class)
-            .version(1)
-            .keySerializer(CachedAccountDetails.Key.Serializer.INSTANCE)
-            .valueSerializer(CachedAccountDetails.Serializer.INSTANCE)
-            .loader(Loader.class);
-
-        bind(AccountCacheImpl.class);
-        bind(AccountCache.class).to(AccountCacheImpl.class);
-      }
-    };
+    return new AccountCacheModule();
   }
 
   private final ExternalIds externalIds;
@@ -122,13 +129,6 @@
   public Map<Account.Id, AccountState> get(Set<Account.Id> accountIds) {
     try {
       try (Repository allUsers = repoManager.openRepository(allUsersName)) {
-        // Get the default preferences for this Gerrit host
-        Ref ref = allUsers.exactRef(RefNames.REFS_USERS_DEFAULT);
-        CachedPreferences defaultPreferences =
-            ref != null
-                ? defaultPreferenceCache.get(ref.getObjectId())
-                : DefaultPreferencesCache.EMPTY;
-
         Set<CachedAccountDetails.Key> keys =
             Sets.newLinkedHashSetWithExpectedSize(accountIds.size());
         for (Account.Id id : accountIds) {
@@ -138,6 +138,7 @@
           }
           keys.add(CachedAccountDetails.Key.create(id, userRef.getObjectId()));
         }
+        CachedPreferences defaultPreferences = defaultPreferenceCache.get();
         ImmutableMap.Builder<Account.Id, AccountState> result = ImmutableMap.builder();
         for (Map.Entry<CachedAccountDetails.Key, CachedAccountDetails> account :
             accountDetailsCache.getAll(keys).entrySet()) {
diff --git a/java/com/google/gerrit/server/account/AccountConfig.java b/java/com/google/gerrit/server/account/AccountConfig.java
index 4143f77..2b0ba3f 100644
--- a/java/com/google/gerrit/server/account/AccountConfig.java
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -26,13 +26,11 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
-import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.CachedPreferences;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
-import com.google.gerrit.server.util.time.TimeUtil;
 import java.io.IOException;
 import java.time.Instant;
 import java.util.ArrayList;
@@ -95,7 +93,7 @@
   }
 
   @Override
-  protected String getRefName() {
+  public String getRefName() {
     return ref;
   }
 
@@ -125,7 +123,8 @@
    * Returns the revision of the {@code refs/meta/external-ids} branch.
    *
    * <p>This revision can be used to load the external IDs of the loaded account lazily via {@link
-   * ExternalIds#byAccount(com.google.gerrit.entities.Account.Id, ObjectId)}.
+   * com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdsNoteDbImpl#byAccount(com.google.gerrit.entities.Account.Id,
+   * ObjectId)}.
    *
    * @return revision of the {@code refs/meta/external-ids} branch, {@link Optional#empty()} if no
    *     {@code refs/meta/external-ids} branch exists
@@ -176,17 +175,7 @@
    * @return the new account
    * @throws DuplicateKeyException if the user branch already exists
    */
-  public Account getNewAccount() throws DuplicateKeyException {
-    return getNewAccount(TimeUtil.now());
-  }
-
-  /**
-   * Creates a new account.
-   *
-   * @return the new account
-   * @throws DuplicateKeyException if the user branch already exists
-   */
-  Account getNewAccount(Instant registeredOn) throws DuplicateKeyException {
+  public Account getNewAccount(Instant registeredOn) throws DuplicateKeyException {
     checkLoaded();
     if (revision != null) {
       throw new DuplicateKeyException(String.format("account %s already exists", accountId));
@@ -205,9 +194,9 @@
    * Returns the content of the {@code preferences.config} file wrapped as {@link
    * CachedPreferences}.
    */
-  CachedPreferences asCachedPreferences() {
+  public CachedPreferences asCachedPreferences() {
     checkLoaded();
-    return CachedPreferences.fromConfig(preferences.getRaw());
+    return CachedPreferences.fromLegacyConfig(preferences.getRaw());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/account/AccountDelta.java b/java/com/google/gerrit/server/account/AccountDelta.java
index 35d354c..89bd078 100644
--- a/java/com/google/gerrit/server/account/AccountDelta.java
+++ b/java/com/google/gerrit/server/account/AccountDelta.java
@@ -18,6 +18,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
@@ -161,6 +162,15 @@
    */
   public abstract Optional<EditPreferencesInfo> getEditPreferences();
 
+  /**
+   * Returns whether the delta for this account is deleting the account.
+   *
+   * <p>If set to true, deletion takes precedence on any other change in this delta.
+   *
+   * @return whether the account should be deleted.
+   */
+  public abstract Optional<Boolean> getShouldDeleteAccount();
+
   public boolean hasExternalIdUpdates() {
     return !this.getCreatedExternalIds().isEmpty()
         || !this.getDeletedExternalIds().isEmpty()
@@ -183,6 +193,7 @@
      *
      * @param fullName the new full name, if {@code null} or empty string the full name is unset
      */
+    @CanIgnoreReturnValue
     public abstract Builder setFullName(@Nullable String fullName);
 
     /**
@@ -191,6 +202,7 @@
      * @param displayName the new display name, if {@code null} or empty string the display name is
      *     unset
      */
+    @CanIgnoreReturnValue
     public abstract Builder setDisplayName(@Nullable String displayName);
 
     /**
@@ -199,6 +211,7 @@
      * @param preferredEmail the new preferred email, if {@code null} or empty string the preferred
      *     email is unset
      */
+    @CanIgnoreReturnValue
     public abstract Builder setPreferredEmail(@Nullable String preferredEmail);
 
     /**
@@ -207,6 +220,7 @@
      * @param active {@code true} if the account should be set to active, {@code false} if the
      *     account should be set to inactive
      */
+    @CanIgnoreReturnValue
     public abstract Builder setActive(boolean active);
 
     /**
@@ -214,6 +228,7 @@
      *
      * @param status the new status, if {@code null} or empty string the status is unset
      */
+    @CanIgnoreReturnValue
     public abstract Builder setStatus(@Nullable String status);
 
     /**
@@ -235,6 +250,7 @@
      * @param extId external ID that should be added
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public Builder addExternalId(ExternalId extId) {
       return addExternalIds(ImmutableSet.of(extId));
     }
@@ -251,6 +267,7 @@
      * @param extIds external IDs that should be added
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public Builder addExternalIds(Collection<ExternalId> extIds) {
       createdExternalIdsBuilder().addAll(extIds);
       return this;
@@ -274,6 +291,7 @@
      * @param extId external ID that should be updated
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public Builder updateExternalId(ExternalId extId) {
       return updateExternalIds(ImmutableSet.of(extId));
     }
@@ -290,6 +308,7 @@
      * @param extIds external IDs that should be updated
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public Builder updateExternalIds(Collection<ExternalId> extIds) {
       updatedExternalIdsBuilder().addAll(extIds);
       return this;
@@ -313,6 +332,7 @@
      * @param extId external ID that should be deleted
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public Builder deleteExternalId(ExternalId extId) {
       return deleteExternalIds(ImmutableSet.of(extId));
     }
@@ -328,6 +348,7 @@
      * @param extIds external IDs that should be deleted
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public Builder deleteExternalIds(Collection<ExternalId> extIds) {
       deletedExternalIdsBuilder().addAll(extIds);
       return this;
@@ -340,6 +361,7 @@
      * @param extIdToAdd external ID that should be added
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public Builder replaceExternalId(ExternalId extIdToDelete, ExternalId extIdToAdd) {
       return replaceExternalIds(ImmutableSet.of(extIdToDelete), ImmutableSet.of(extIdToAdd));
     }
@@ -351,6 +373,7 @@
      * @param extIdsToAdd external IDs that should be added
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public Builder replaceExternalIds(
         Collection<ExternalId> extIdsToDelete, Collection<ExternalId> extIdsToAdd) {
       return deleteExternalIds(extIdsToDelete).addExternalIds(extIdsToAdd);
@@ -372,6 +395,7 @@
      * @param notifyTypes the notify types that should be set for the project watch
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public Builder updateProjectWatch(
         ProjectWatchKey projectWatchKey, Set<NotifyType> notifyTypes) {
       return updateProjectWatches(ImmutableMap.of(projectWatchKey, notifyTypes));
@@ -386,6 +410,7 @@
      * @param projectWatches project watches that should be updated
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public Builder updateProjectWatches(Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
       updatedProjectWatchesBuilder().putAll(projectWatches);
       return this;
@@ -406,6 +431,7 @@
      * @param projectWatch project watch that should be deleted
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public Builder deleteProjectWatch(ProjectWatchKey projectWatch) {
       return deleteProjectWatches(ImmutableSet.of(projectWatch));
     }
@@ -418,6 +444,7 @@
      * @param projectWatches project watches that should be deleted
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public Builder deleteProjectWatches(Collection<ProjectWatchKey> projectWatches) {
       deletedProjectWatchesBuilder().addAll(projectWatches);
       return this;
@@ -431,6 +458,7 @@
      * @param generalPreferences the general preferences that should be set
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public abstract Builder setGeneralPreferences(GeneralPreferencesInfo generalPreferences);
 
     /**
@@ -441,6 +469,7 @@
      * @param diffPreferences the diff preferences that should be set
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public abstract Builder setDiffPreferences(DiffPreferencesInfo diffPreferences);
 
     /**
@@ -451,8 +480,25 @@
      * @param editPreferences the edit preferences that should be set
      * @return the builder
      */
+    @CanIgnoreReturnValue
     public abstract Builder setEditPreferences(EditPreferencesInfo editPreferences);
 
+    @CanIgnoreReturnValue
+    public abstract Builder setShouldDeleteAccount(boolean shouldDelete);
+
+    /**
+     * Builds an AccountDelta that deletes all data.
+     *
+     * @param extIdsToDelete external IDs that should be deleted
+     * @return the builder
+     */
+    @CanIgnoreReturnValue
+    public Builder deleteAccount(Collection<ExternalId> extIdsToDelete) {
+      deleteExternalIds(extIdsToDelete);
+      setShouldDeleteAccount(true);
+      return this;
+    }
+
     /** Builds the instance. */
     public abstract AccountDelta build();
 
@@ -603,6 +649,12 @@
         delegate.setEditPreferences(editPreferences);
         return this;
       }
+
+      @Override
+      public Builder setShouldDeleteAccount(boolean shouldDelete) {
+        delegate.setShouldDeleteAccount(shouldDelete);
+        return this;
+      }
     }
   }
 }
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index f5e9dcc..18260a4 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -45,7 +46,6 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.inject.Inject;
@@ -54,6 +54,7 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
@@ -505,28 +506,30 @@
    */
   public AuthResult updateLink(Account.Id to, AuthRequest who)
       throws AccountException, IOException, ConfigInvalidException {
+    Optional<ExternalId> optionalExtId = externalIds.get(who.getExternalIdKey());
+    if (optionalExtId.filter(extId -> !extId.accountId().equals(to)).isPresent()) {
+      throw new AccountException(
+          "Identity '" + optionalExtId.get().key().get() + "' in use by another account");
+    }
+
     accountsUpdateProvider
         .get()
         .update(
-            "Delete External IDs on Update Link",
+            "Update External IDs on Update Link",
             to,
             (a, u) -> {
               Set<ExternalId> filteredExtIdsByScheme =
                   a.externalIds().stream()
                       .filter(e -> e.key().isScheme(who.getExternalIdKey().scheme()))
                       .collect(toImmutableSet());
-              if (filteredExtIdsByScheme.isEmpty()) {
-                return;
-              }
+              ExternalId newExtId =
+                  externalIdFactory.createWithEmail(
+                      who.getExternalIdKey(), to, who.getEmailAddress());
 
-              if (filteredExtIdsByScheme.size() > 1
-                  || filteredExtIdsByScheme.stream()
-                      .noneMatch(e -> e.key().equals(who.getExternalIdKey()))) {
-                u.deleteExternalIds(filteredExtIdsByScheme);
-              }
+              u.replaceExternalIds(filteredExtIdsByScheme, Collections.singletonList(newExtId));
             });
 
-    return link(to, who);
+    return new AuthResult(to, who.getExternalIdKey(), false);
   }
 
   /**
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 490643f..20a12f4 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -26,7 +26,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Streams;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.index.Schema;
@@ -299,7 +300,7 @@
   private abstract class AccountIdSearcher implements Searcher<Account.Id> {
     @Override
     public final Stream<AccountState> search(Account.Id input) {
-      return Streams.stream(accountCache.get(input));
+      return accountCache.get(input).stream();
     }
   }
 
@@ -373,7 +374,7 @@
 
     @Override
     public Stream<AccountState> search(String input) {
-      return Streams.stream(accountCache.getByUsername(input));
+      return accountCache.getByUsername(input).stream();
     }
 
     @Override
@@ -585,6 +586,13 @@
           .addAll(nameOrEmailSearchers)
           .build();
 
+  private final ImmutableList<Searcher<?>> exactSearchers =
+      ImmutableList.<Searcher<?>>builder()
+          .add(new BySelf())
+          .add(new ByExactAccountId())
+          .add(new ByEmail())
+          .build();
+
   private final AccountCache accountCache;
   private final AccountControl.Factory accountControlFactory;
   private final Emails emails;
@@ -650,6 +658,17 @@
         input, searchers, self.get(), this::currentUserCanSeePredicate, AccountResolver::isActive);
   }
 
+  /** Resolves accounts using exact searchers. Similar to the previous method. */
+  @UsedAt(Project.GOOGLE)
+  public Result resolveExact(String input) throws ConfigInvalidException, IOException {
+    return searchImpl(
+        input,
+        exactSearchers,
+        self.get(),
+        this::currentUserCanSeePredicate,
+        AccountResolver::isActive);
+  }
+
   public Result resolve(String input, Predicate<AccountState> accountActivityPredicate)
       throws ConfigInvalidException, IOException {
     return searchImpl(
diff --git a/java/com/google/gerrit/server/account/AccountState.java b/java/com/google/gerrit/server/account/AccountState.java
index a600c12..8f0cb2a 100644
--- a/java/com/google/gerrit/server/account/AccountState.java
+++ b/java/com/google/gerrit/server/account/AccountState.java
@@ -18,7 +18,6 @@
 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.Account;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
@@ -26,13 +25,11 @@
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.server.account.ProjectWatches.ProjectWatchKey;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.config.CachedPreferences;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Optional;
-import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * Superset of all information related to an Account. This includes external IDs, project watches,
@@ -43,72 +40,6 @@
  */
 @AutoValue
 public abstract class AccountState {
-  /**
-   * Creates an AccountState from the given account config.
-   *
-   * @param externalIds class to access external IDs
-   * @param accountConfig the account config, must already be loaded
-   * @param defaultPreferences the default preferences for this Gerrit installation
-   * @return the account state, {@link Optional#empty()} if the account doesn't exist
-   * @throws IOException if accessing the external IDs fails
-   */
-  public static Optional<AccountState> fromAccountConfig(
-      ExternalIds externalIds, AccountConfig accountConfig, CachedPreferences defaultPreferences)
-      throws IOException {
-    return fromAccountConfig(externalIds, accountConfig, null, defaultPreferences);
-  }
-
-  /**
-   * Creates an AccountState from the given account config.
-   *
-   * <p>If external ID notes are provided the revision of the external IDs branch from which the
-   * external IDs for the account should be loaded is taken from the external ID notes. If external
-   * ID notes are not given the revision of the external IDs branch is taken from the account
-   * config. Updating external IDs is done via {@link ExternalIdNotes} and if external IDs were
-   * updated the revision of the external IDs branch in account config is outdated. Hence after
-   * updating external IDs the external ID notes must be provided.
-   *
-   * @param externalIds class to access external IDs
-   * @param accountConfig the account config, must already be loaded
-   * @param extIdNotes external ID notes, must already be loaded, may be {@code null}
-   * @param defaultPreferences the default preferences for this Gerrit installation
-   * @return the account state, {@link Optional#empty()} if the account doesn't exist
-   * @throws IOException if accessing the external IDs fails
-   */
-  public static Optional<AccountState> fromAccountConfig(
-      ExternalIds externalIds,
-      AccountConfig accountConfig,
-      @Nullable ExternalIdNotes extIdNotes,
-      CachedPreferences defaultPreferences)
-      throws IOException {
-    if (!accountConfig.getLoadedAccount().isPresent()) {
-      return Optional.empty();
-    }
-    Account account = accountConfig.getLoadedAccount().get();
-
-    Optional<ObjectId> extIdsRev =
-        extIdNotes != null
-            ? Optional.ofNullable(extIdNotes.getRevision())
-            : accountConfig.getExternalIdsRev();
-    ImmutableSet<ExternalId> extIds =
-        extIdsRev.isPresent()
-            ? externalIds.byAccount(account.id(), extIdsRev.get())
-            : ImmutableSet.of();
-
-    // Don't leak references to AccountConfig into the AccountState, since it holds a reference to
-    // an open Repository instance.
-    ImmutableMap<ProjectWatchKey, ImmutableSet<NotifyType>> projectWatches =
-        accountConfig.getProjectWatches();
-
-    return Optional.of(
-        new AutoValue_AccountState(
-            account,
-            extIds,
-            ExternalId.getUserName(extIds),
-            projectWatches,
-            Optional.of(defaultPreferences),
-            Optional.of(accountConfig.asCachedPreferences())));
-  }
 
   /**
    * Creates an AccountState for a given account with no external IDs, no project watches and
@@ -157,6 +88,18 @@
         Optional.empty());
   }
 
+  /** Creates an AccountState instance containing the given data. */
+  public static AccountState withState(
+      Account account,
+      ImmutableSet<ExternalId> externalIds,
+      Optional<String> userName,
+      ImmutableMap<ProjectWatches.ProjectWatchKey, ImmutableSet<NotifyType>> projectWatches,
+      Optional<CachedPreferences> defaultPreferences,
+      Optional<CachedPreferences> userPreferences) {
+    return new AutoValue_AccountState(
+        account, externalIds, userName, projectWatches, defaultPreferences, userPreferences);
+  }
+
   /** Get the cached account metadata. */
   public abstract Account account();
 
@@ -204,8 +147,8 @@
   }
 
   /** Gerrit's default preferences as stored in {@code preferences.config}. */
-  protected abstract Optional<CachedPreferences> defaultPreferences();
+  public abstract Optional<CachedPreferences> defaultPreferences();
 
   /** User preferences as stored in {@code preferences.config}. */
-  protected abstract Optional<CachedPreferences> userPreferences();
+  public abstract Optional<CachedPreferences> userPreferences();
 }
diff --git a/java/com/google/gerrit/server/account/Accounts.java b/java/com/google/gerrit/server/account/Accounts.java
index 976a7d89..55192e9 100644
--- a/java/com/google/gerrit/server/account/Accounts.java
+++ b/java/com/google/gerrit/server/account/Accounts.java
@@ -14,107 +14,44 @@
 
 package com.google.gerrit.server.account;
 
-import static java.util.Comparator.comparing;
-import static java.util.stream.Collectors.toList;
-import static java.util.stream.Collectors.toSet;
-
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.metrics.Description;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.metrics.Timer0;
-import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.CachedPreferences;
-import com.google.gerrit.server.config.VersionedDefaultPreferences;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
-import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
-import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Repository;
 
 /** Class to access accounts. */
-@Singleton
-public class Accounts {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+public interface Accounts {
+  /**
+   * Gets the account state for the given ID.
+   *
+   * @return the account state if found, {@code Optional.empty} otherwise.
+   */
+  Optional<AccountState> get(Account.Id accountId) throws IOException, ConfigInvalidException;
 
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-  private final ExternalIds externalIds;
-  private final Timer0 readSingleLatency;
-
-  @Inject
-  Accounts(
-      GitRepositoryManager repoManager,
-      AllUsersName allUsersName,
-      ExternalIds externalIds,
-      MetricMaker metricMaker) {
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-    this.externalIds = externalIds;
-    this.readSingleLatency =
-        metricMaker.newTimer(
-            "notedb/read_single_account_config_latency",
-            new Description("Latency for reading a single account config.")
-                .setCumulative()
-                .setUnit(Description.Units.MILLISECONDS));
-  }
-
-  public Optional<AccountState> get(Account.Id accountId)
-      throws IOException, ConfigInvalidException {
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return read(repo, accountId);
-    }
-  }
-
-  public List<AccountState> get(Collection<Account.Id> accountIds)
-      throws IOException, ConfigInvalidException {
-    List<AccountState> accounts = new ArrayList<>(accountIds.size());
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      for (Account.Id accountId : accountIds) {
-        read(repo, accountId).ifPresent(accounts::add);
-      }
-    }
-    return accounts;
-  }
+  /**
+   * Gets the account states for all the given IDs.
+   *
+   * @return the account states.
+   */
+  List<AccountState> get(Collection<Account.Id> accountIds)
+      throws IOException, ConfigInvalidException;
 
   /**
    * Returns all accounts.
    *
    * @return all accounts
    */
-  public List<AccountState> all() throws IOException {
-    Set<Account.Id> accountIds = allIds();
-    List<AccountState> accounts = new ArrayList<>(accountIds.size());
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      for (Account.Id accountId : accountIds) {
-        try {
-          read(repo, accountId).ifPresent(accounts::add);
-        } catch (Exception e) {
-          logger.atSevere().withCause(e).log("Ignoring invalid account %s", accountId);
-        }
-      }
-    }
-    return accounts;
-  }
+  List<AccountState> all() throws IOException;
 
   /**
    * Returns all account IDs.
    *
    * @return all account IDs
    */
-  public Set<Account.Id> allIds() throws IOException {
-    return readUserRefs().collect(toSet());
-  }
+  Set<Account.Id> allIds() throws IOException;
 
   /**
    * Returns the first n account IDs.
@@ -122,48 +59,12 @@
    * @param n the number of account IDs that should be returned
    * @return first n account IDs
    */
-  public List<Account.Id> firstNIds(int n) throws IOException {
-    return readUserRefs().sorted(comparing(Account.Id::get)).limit(n).collect(toList());
-  }
+  List<Account.Id> firstNIds(int n) throws IOException;
 
   /**
    * Checks if any account exists.
    *
-   * @return {@code true} if at least one account exists, otherwise {@code false}
+   * @return {@code true} if at least one account exists, otherwise {@code false}.
    */
-  public boolean hasAnyAccount() throws IOException {
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return hasAnyAccount(repo);
-    }
-  }
-
-  public static boolean hasAnyAccount(Repository repo) throws IOException {
-    return readUserRefs(repo).findAny().isPresent();
-  }
-
-  private Stream<Account.Id> readUserRefs() throws IOException {
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return readUserRefs(repo);
-    }
-  }
-
-  private Optional<AccountState> read(Repository allUsersRepository, Account.Id accountId)
-      throws IOException, ConfigInvalidException {
-    AccountConfig cfg;
-    CachedPreferences defaultPreferences;
-    try (Timer0.Context ignored = readSingleLatency.start()) {
-      cfg = new AccountConfig(accountId, allUsersName, allUsersRepository).load();
-      defaultPreferences =
-          CachedPreferences.fromConfig(
-              VersionedDefaultPreferences.get(allUsersRepository, allUsersName));
-    }
-
-    return AccountState.fromAccountConfig(externalIds, cfg, defaultPreferences);
-  }
-
-  public static Stream<Account.Id> readUserRefs(Repository repo) throws IOException {
-    return repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_USERS).stream()
-        .map(r -> Account.Id.fromRef(r.getName()))
-        .filter(Objects::nonNull);
-  }
+  boolean hasAnyAccount() throws IOException;
 }
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index b706bca..24e8ba5 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -15,116 +15,41 @@
 package com.google.gerrit.server.account;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.ACCOUNTS_UPDATE;
-import static java.util.Objects.requireNonNull;
-import static java.util.stream.Collectors.toList;
-import static java.util.stream.Collectors.toSet;
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Strings;
-import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.common.Nullable;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.DuplicateKeyException;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.git.LockFailureException;
-import com.google.gerrit.git.RefUpdateUtil;
-import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.externalids.ExternalIdNotes;
-import com.google.gerrit.server.account.externalids.ExternalIdNotes.ExternalIdNotesLoader;
-import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.CachedPreferences;
-import com.google.gerrit.server.config.VersionedDefaultPreferences;
-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.change.ReindexAfterRefUpdate;
-import com.google.gerrit.server.notedb.Sequences;
-import com.google.gerrit.server.update.RetryHelper;
-import com.google.gerrit.server.update.RetryableAction.Action;
-import com.google.gerrit.server.update.context.RefUpdateContext;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
+import com.google.gerrit.server.Sequences;
+import com.google.inject.BindingAnnotation;
 import java.io.IOException;
-import java.util.ArrayList;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
 import java.util.List;
-import java.util.Objects;
 import java.util.Optional;
-import java.util.Set;
 import java.util.function.Consumer;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-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.
  *
- * <p>This class should be used for all account updates. See {@link AccountDelta} for what can be
- * updated.
- *
- * <p>Batch updates of multiple different accounts can be performed atomically, see {@link
- * #updateBatch(List)}. Batch creation is not supported.
- *
- * <p>For any account update the caller must provide a commit message, the account ID and an {@link
- * ConfigureDeltaFromState}. The account updater reads the current {@link AccountState} and prepares
- * updates to the account by calling setters on the provided {@link AccountDelta.Builder}. If the
- * current account state is of no interest the caller may also provide a {@link Consumer} for {@link
- * AccountDelta.Builder} instead of the account updater.
- *
- * <p>The provided commit message is used for the update of the user branch. Using a precise and
- * unique commit message allows to identify the code from which an update was made when looking at a
- * commit in the user branch, and thus help debugging.
+ * <p>This interface should be used for all account updates. See {@link AccountDelta} for what can
+ * be updated.
  *
  * <p>For creating a new account a new account ID can be retrieved from {@link
  * Sequences#nextAccountId()}.
  *
- * <p>The account updates are written to NoteDb. In NoteDb accounts are represented as user branches
- * in the {@code All-Users} repository. Optionally a user branch can contain a 'account.config' file
- * that stores account properties, such as full name, display name, preferred email, status and the
- * active flag. The timestamp of the first commit on a user branch denotes the registration date.
- * The initial commit on the user branch may be empty (since having an 'account.config' is
- * optional). See {@link AccountConfig} for details of the 'account.config' file format. In addition
- * the user branch can contain a 'preferences.config' config file to store preferences (see {@link
- * StoredPreferences}) and a 'watch.config' config file to store project watches (see {@link
- * ProjectWatches}). External IDs are stored separately in the {@code refs/meta/external-ids} notes
- * branch (see {@link ExternalIdNotes}).
- *
- * <p>On updating an account the account is evicted from the account cache and reindexed. The
- * eviction from the account cache and the reindexing is done by the {@link ReindexAfterRefUpdate}
- * class which receives the event about updating the user branch that is triggered by this class.
- *
- * <p>If external IDs are updated, the ExternalIdCache is automatically updated by {@link
- * ExternalIdNotes}. In addition {@link ExternalIdNotes} takes care about evicting and reindexing
- * corresponding accounts. This is needed because external ID updates don't touch the user branches.
- * Hence in this case the accounts are not evicted and reindexed via {@link ReindexAfterRefUpdate}.
- *
- * <p>Reindexing and flushing accounts from the account cache can be disabled by
- *
- * <ul>
- *   <li>binding {@link GitReferenceUpdated#DISABLED} and
- *   <li>passing an {@link
- *       com.google.gerrit.server.account.externalids.ExternalIdNotes.FactoryNoReindex} factory as
- *       parameter of {@link AccountsUpdate.Factory#create(IdentifiedUser,
- *       ExternalIdNotes.ExternalIdNotesLoader)}
- * </ul>
- *
- * <p>If there are concurrent account updates updating the user branch in NoteDb may fail with
- * {@link LockFailureException}. In this case the account update is automatically retried and the
- * account updater is invoked once more with the updated account state. This means the whole
- * read-modify-write sequence is atomic. Retrying is limited by a timeout. If the timeout is
- * exceeded the account update can still fail with {@link LockFailureException}.
+ * <p>See the implementing classes for more information.
  */
-public class AccountsUpdate {
-  public interface Factory {
+public abstract class AccountsUpdate {
+  public interface AccountsUpdateLoader {
     /**
      * Creates an {@code AccountsUpdate} which uses the identity of the specified user as author for
      * all commits related to accounts. The server identity will be used as committer.
@@ -134,9 +59,8 @@
      * AccountsUpdate} instead.
      *
      * @param currentUser the user to which modifications should be attributed
-     * @param externalIdNotesLoader the loader that should be used to load external ID notes
      */
-    AccountsUpdate create(IdentifiedUser currentUser, ExternalIdNotesLoader externalIdNotesLoader);
+    AccountsUpdate create(IdentifiedUser currentUser);
 
     /**
      * Creates an {@code AccountsUpdate} which uses the server identity as author and committer for
@@ -145,10 +69,34 @@
      * <p><strong>Note</strong>: Please use this method with care and consider using the {@link
      * com.google.gerrit.server.ServerInitiated} annotation on the provider of an {@code
      * AccountsUpdate} instead.
-     *
-     * @param externalIdNotesLoader the loader that should be used to load external ID notes
      */
-    AccountsUpdate createWithServerIdent(ExternalIdNotesLoader externalIdNotesLoader);
+    AccountsUpdate createWithServerIdent();
+
+    @BindingAnnotation
+    @Target({FIELD, PARAMETER, METHOD})
+    @Retention(RUNTIME)
+    @interface WithReindex {}
+
+    @BindingAnnotation
+    @Target({FIELD, PARAMETER, METHOD})
+    @Retention(RUNTIME)
+    @interface NoReindex {}
+  }
+
+  /** Data holder for the set of arguments required to update an account. Used for batch updates. */
+  public static class UpdateArguments {
+    public final String message;
+    public final Account.Id accountId;
+    public final AccountsUpdate.ConfigureDeltaFromState configureDeltaFromState;
+
+    public UpdateArguments(
+        String message,
+        Account.Id accountId,
+        AccountsUpdate.ConfigureDeltaFromState configureDeltaFromState) {
+      this.message = message;
+      this.accountId = accountId;
+      this.configureDeltaFromState = configureDeltaFromState;
+    }
   }
 
   /**
@@ -156,13 +104,13 @@
    * delta to be applied to it in a later step. This is done by implementing this interface.
    *
    * <p>If the current account state is not needed, use a {@link Consumer} of {@link
-   * AccountDelta.Builder} instead.
+   * com.google.gerrit.server.account.AccountDelta.Builder} instead.
    */
   @FunctionalInterface
   public interface ConfigureDeltaFromState {
     /**
      * Receives the current {@link AccountState} (which is immutable) and configures an {@link
-     * AccountDelta.Builder} with changes to the account.
+     * com.google.gerrit.server.account.AccountDelta.Builder} with changes to the account.
      *
      * @param accountState the state of the account that is being updated
      * @param delta the changes to be applied
@@ -170,133 +118,25 @@
     void configure(AccountState accountState, AccountDelta.Builder delta) throws IOException;
   }
 
-  /** Data holder for the set of arguments required to update an account. Used for batch updates. */
-  public static class UpdateArguments {
-    private final String message;
-    private final Account.Id accountId;
-    private final ConfigureDeltaFromState configureDeltaFromState;
-
-    public UpdateArguments(
-        String message, Account.Id accountId, ConfigureDeltaFromState configureDeltaFromState) {
-      this.message = message;
-      this.accountId = accountId;
-      this.configureDeltaFromState = configureDeltaFromState;
-    }
-  }
-
-  private final GitRepositoryManager repoManager;
-  private final GitReferenceUpdated gitRefUpdated;
-  private final Optional<IdentifiedUser> currentUser;
-  private final AllUsersName allUsersName;
-  private final ExternalIds externalIds;
-  private final Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
-  private final RetryHelper retryHelper;
-  private final ExternalIdNotesLoader extIdNotesLoader;
-  private final PersonIdent committerIdent;
-  private final PersonIdent authorIdent;
-
-  /** Invoked after reading the account config. */
-  private final Runnable afterReadRevision;
-
-  /** Invoked after updating the account but before committing the changes. */
-  private final Runnable beforeCommit;
-
-  /** Single instance that accumulates updates from the batch. */
-  @Nullable private ExternalIdNotes externalIdNotes;
-
-  @AssistedInject
-  AccountsUpdate(
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated gitRefUpdated,
-      AllUsersName allUsersName,
-      ExternalIds externalIds,
-      Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
-      RetryHelper retryHelper,
-      @GerritPersonIdent PersonIdent serverIdent,
-      @Assisted ExternalIdNotesLoader extIdNotesLoader) {
-    this(
-        repoManager,
-        gitRefUpdated,
-        Optional.empty(),
-        allUsersName,
-        externalIds,
-        metaDataUpdateInternalFactory,
-        retryHelper,
-        extIdNotesLoader,
-        serverIdent,
-        createPersonIdent(serverIdent, Optional.empty()),
-        AccountsUpdate::doNothing,
-        AccountsUpdate::doNothing);
-  }
-
-  @AssistedInject
-  AccountsUpdate(
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated gitRefUpdated,
-      AllUsersName allUsersName,
-      ExternalIds externalIds,
-      Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
-      RetryHelper retryHelper,
-      @GerritPersonIdent PersonIdent serverIdent,
-      @Assisted IdentifiedUser currentUser,
-      @Assisted ExternalIdNotesLoader extIdNotesLoader) {
-    this(
-        repoManager,
-        gitRefUpdated,
-        Optional.of(currentUser),
-        allUsersName,
-        externalIds,
-        metaDataUpdateInternalFactory,
-        retryHelper,
-        extIdNotesLoader,
-        serverIdent,
-        createPersonIdent(serverIdent, Optional.of(currentUser)),
-        AccountsUpdate::doNothing,
-        AccountsUpdate::doNothing);
-  }
-
-  @VisibleForTesting
-  public AccountsUpdate(
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated gitRefUpdated,
-      Optional<IdentifiedUser> currentUser,
-      AllUsersName allUsersName,
-      ExternalIds externalIds,
-      Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
-      RetryHelper retryHelper,
-      ExternalIdNotesLoader extIdNotesLoader,
-      PersonIdent committerIdent,
-      PersonIdent authorIdent,
-      Runnable afterReadRevision,
-      Runnable beforeCommit) {
-    this.repoManager = requireNonNull(repoManager, "repoManager");
-    this.gitRefUpdated = requireNonNull(gitRefUpdated, "gitRefUpdated");
-    this.currentUser = currentUser;
-    this.allUsersName = requireNonNull(allUsersName, "allUsersName");
-    this.externalIds = requireNonNull(externalIds, "externalIds");
-    this.metaDataUpdateInternalFactory =
-        requireNonNull(metaDataUpdateInternalFactory, "metaDataUpdateInternalFactory");
-    this.retryHelper = requireNonNull(retryHelper, "retryHelper");
-    this.extIdNotesLoader = requireNonNull(extIdNotesLoader, "extIdNotesLoader");
-    this.committerIdent = requireNonNull(committerIdent, "committerIdent");
-    this.authorIdent = requireNonNull(authorIdent, "authorIdent");
-    this.afterReadRevision = requireNonNull(afterReadRevision, "afterReadRevision");
-    this.beforeCommit = requireNonNull(beforeCommit, "beforeCommit");
-  }
-
   /** Returns an instance that runs all specified consumers. */
   public static ConfigureDeltaFromState joinConsumers(
       List<Consumer<AccountDelta.Builder>> consumers) {
     return (accountStateIgnored, update) -> consumers.forEach(c -> c.accept(update));
   }
 
-  private static ConfigureDeltaFromState fromConsumer(Consumer<AccountDelta.Builder> consumer) {
+  static ConfigureDeltaFromState fromConsumer(Consumer<AccountDelta.Builder> consumer) {
     return (a, u) -> consumer.accept(u);
   }
 
-  private static PersonIdent createPersonIdent(
-      PersonIdent serverIdent, Optional<IdentifiedUser> user) {
-    return user.isPresent() ? user.get().newCommitterIdent(serverIdent) : serverIdent;
+  protected final PersonIdent committerIdent;
+  protected final PersonIdent authorIdent;
+
+  protected final Optional<IdentifiedUser> currentUser;
+
+  protected AccountsUpdate(PersonIdent serverIdent, Optional<IdentifiedUser> user) {
+    this.currentUser = user;
+    this.committerIdent = serverIdent;
+    this.authorIdent = createPersonIdent(serverIdent, user);
   }
 
   /**
@@ -307,7 +147,7 @@
   public AccountState insert(
       String message, Account.Id accountId, Consumer<AccountDelta.Builder> init)
       throws IOException, ConfigInvalidException {
-    return insert(message, accountId, fromConsumer(init));
+    return insert(message, accountId, AccountsUpdate.fromConsumer(init));
   }
 
   /**
@@ -321,40 +161,19 @@
    * @throws IOException if creating the user branch fails due to an IO error
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
-  public AccountState insert(String message, Account.Id accountId, ConfigureDeltaFromState init)
-      throws IOException, ConfigInvalidException {
-    return execute(
-            ImmutableList.of(
-                repo -> {
-                  AccountConfig accountConfig = read(repo, accountId);
-                  Account account = accountConfig.getNewAccount(committerIdent.getWhenAsInstant());
-                  AccountState accountState = AccountState.forAccount(account);
-                  AccountDelta.Builder deltaBuilder = AccountDelta.builder();
-                  init.configure(accountState, deltaBuilder);
-
-                  AccountDelta accountDelta = deltaBuilder.build();
-                  accountConfig.setAccountDelta(accountDelta);
-                  externalIdNotes =
-                      createExternalIdNotes(
-                          repo, accountConfig.getExternalIdsRev(), accountId, accountDelta);
-                  CachedPreferences defaultPreferences =
-                      CachedPreferences.fromConfig(
-                          VersionedDefaultPreferences.get(repo, allUsersName));
-
-                  return new UpdatedAccount(message, accountConfig, defaultPreferences, true);
-                }))
-        .get(0)
-        .get();
-  }
+  public abstract AccountState insert(
+      String message, Account.Id accountId, ConfigureDeltaFromState init)
+      throws IOException, ConfigInvalidException;
 
   /**
    * Like {@link #update(String, Account.Id, ConfigureDeltaFromState)}, but using a {@link Consumer}
    * instead, i.e. the update does not depend on the current account state.
    */
+  @CanIgnoreReturnValue
   public Optional<AccountState> update(
       String message, Account.Id accountId, Consumer<AccountDelta.Builder> update)
       throws IOException, ConfigInvalidException {
-    return update(message, accountId, fromConsumer(update));
+    return update(message, accountId, AccountsUpdate.fromConsumer(update));
   }
 
   /**
@@ -372,57 +191,15 @@
    *     after the retry timeout exceeded
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
+  @CanIgnoreReturnValue
   public Optional<AccountState> update(
       String message, Account.Id accountId, ConfigureDeltaFromState configureDeltaFromState)
-      throws LockFailureException, IOException, ConfigInvalidException {
+      throws IOException, ConfigInvalidException {
     return updateBatch(
             ImmutableList.of(new UpdateArguments(message, accountId, configureDeltaFromState)))
         .get(0);
   }
 
-  private ExecutableUpdate createExecutableUpdate(UpdateArguments updateArguments) {
-    return repo -> {
-      AccountConfig accountConfig = read(repo, updateArguments.accountId);
-      CachedPreferences defaultPreferences =
-          CachedPreferences.fromConfig(VersionedDefaultPreferences.get(repo, allUsersName));
-      Optional<AccountState> accountState =
-          AccountState.fromAccountConfig(externalIds, accountConfig, defaultPreferences);
-      if (!accountState.isPresent()) {
-        return null;
-      }
-
-      AccountDelta.Builder deltaBuilder = AccountDelta.builder();
-      updateArguments.configureDeltaFromState.configure(accountState.get(), deltaBuilder);
-
-      AccountDelta delta = deltaBuilder.build();
-      accountConfig.setAccountDelta(delta);
-      ExternalIdNotes.checkSameAccount(
-          Iterables.concat(
-              delta.getCreatedExternalIds(),
-              delta.getUpdatedExternalIds(),
-              delta.getDeletedExternalIds()),
-          updateArguments.accountId);
-
-      if (delta.hasExternalIdUpdates()) {
-        // Only load the externalIds if they are going to be updated
-        // This makes e.g. preferences updates faster.
-        if (externalIdNotes == null) {
-          externalIdNotes =
-              extIdNotesLoader.load(
-                  repo, accountConfig.getExternalIdsRev().orElse(ObjectId.zeroId()));
-        }
-        externalIdNotes.replace(delta.getDeletedExternalIds(), delta.getCreatedExternalIds());
-        externalIdNotes.upsert(delta.getUpdatedExternalIds());
-      }
-
-      CachedPreferences cachedDefaultPreferences =
-          CachedPreferences.fromConfig(VersionedDefaultPreferences.get(repo, allUsersName));
-
-      return new UpdatedAccount(
-          updateArguments.message, accountConfig, cachedDefaultPreferences, false);
-    };
-  }
-
   /**
    * Updates multiple different accounts atomically. This will only store a single new value (aka
    * set of all external IDs of the host) in the external ID cache, which is important for storage
@@ -438,200 +215,25 @@
     checkArgument(
         updates.stream().map(u -> u.accountId.get()).distinct().count() == updates.size(),
         "updates must all be for different accounts");
-    return execute(updates.stream().map(this::createExecutableUpdate).collect(toList()));
+    return executeUpdates(updates);
   }
 
-  private AccountConfig read(Repository allUsersRepo, Account.Id accountId)
-      throws IOException, ConfigInvalidException {
-    AccountConfig accountConfig = new AccountConfig(accountId, allUsersName, allUsersRepo).load();
-    afterReadRevision.run();
-    return accountConfig;
-  }
+  /**
+   * Deletes all the account state data.
+   *
+   * @param message commit message for the account update, must not be {@code null or empty}
+   * @param accountId ID of the account
+   * @throws IOException if updating the user branch fails due to an IO error
+   * @throws ConfigInvalidException if any of the account fields has an invalid value
+   */
+  public abstract void delete(String message, Account.Id accountId)
+      throws IOException, ConfigInvalidException;
 
-  private ImmutableList<Optional<AccountState>> execute(List<ExecutableUpdate> executableUpdates)
-      throws IOException, ConfigInvalidException {
-    try (RefUpdateContext ctx = RefUpdateContext.open(ACCOUNTS_UPDATE)) {
-      List<Optional<AccountState>> accountState = new ArrayList<>();
-      List<UpdatedAccount> updatedAccounts = new ArrayList<>();
-      executeWithRetry(
-          () -> {
+  protected abstract ImmutableList<Optional<AccountState>> executeUpdates(
+      List<UpdateArguments> updates) throws ConfigInvalidException, IOException;
 
-            // Reset state for retry.
-            externalIdNotes = null;
-            accountState.clear();
-            updatedAccounts.clear();
-            try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
-              for (ExecutableUpdate executableUpdate : executableUpdates) {
-                updatedAccounts.add(executableUpdate.execute(allUsersRepo));
-              }
-              commit(
-                  allUsersRepo,
-                  updatedAccounts.stream().filter(Objects::nonNull).collect(toList()));
-              for (UpdatedAccount ua : updatedAccounts) {
-                accountState.add(ua == null ? Optional.empty() : ua.getAccountState());
-              }
-            }
-            return null;
-          });
-
-      return ImmutableList.copyOf(accountState);
-    }
-  }
-
-  private void executeWithRetry(Action<Void> action) throws IOException, ConfigInvalidException {
-    try {
-      retryHelper.accountUpdate("updateAccount", action).call();
-    } catch (Exception e) {
-      Throwables.throwIfUnchecked(e);
-      Throwables.throwIfInstanceOf(e, IOException.class);
-      Throwables.throwIfInstanceOf(e, ConfigInvalidException.class);
-      throw new StorageException(e);
-    }
-  }
-
-  private ExternalIdNotes createExternalIdNotes(
-      Repository allUsersRepo, Optional<ObjectId> rev, Account.Id accountId, AccountDelta update)
-      throws IOException, ConfigInvalidException, DuplicateKeyException {
-    ExternalIdNotes.checkSameAccount(
-        Iterables.concat(
-            update.getCreatedExternalIds(),
-            update.getUpdatedExternalIds(),
-            update.getDeletedExternalIds()),
-        accountId);
-
-    ExternalIdNotes extIdNotes = extIdNotesLoader.load(allUsersRepo, rev.orElse(ObjectId.zeroId()));
-    extIdNotes.replace(update.getDeletedExternalIds(), update.getCreatedExternalIds());
-    extIdNotes.upsert(update.getUpdatedExternalIds());
-    return extIdNotes;
-  }
-
-  private void commit(Repository allUsersRepo, List<UpdatedAccount> updatedAccounts)
-      throws IOException {
-    if (updatedAccounts.isEmpty()) {
-      return;
-    }
-
-    beforeCommit.run();
-
-    BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
-    //  External ids may be not updated if:
-    //  * externalIdNotes is not loaded  (there were no externalId updates in the delta)
-    //  * new revCommit is identical to the previous externalId tip
-    boolean externalIdsUpdated = false;
-    if (externalIdNotes != null) {
-      String externalIdUpdateMessage =
-          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);
-      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,
-          allowEmptyCommit);
-    }
-
-    RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
-
-    Set<Account.Id> accountsToSkipForReindex = getUpdatedAccountIds(batchRefUpdate);
-    if (externalIdsUpdated) {
-      extIdNotesLoader.updateExternalIdCacheAndMaybeReindexAccounts(
-          externalIdNotes, accountsToSkipForReindex);
-    }
-
-    gitRefUpdated.fire(
-        allUsersName, batchRefUpdate, currentUser.map(IdentifiedUser::state).orElse(null));
-  }
-
-  private static Set<Account.Id> getUpdatedAccountIds(BatchRefUpdate batchRefUpdate) {
-    return batchRefUpdate.getCommands().stream()
-        .map(c -> Account.Id.fromRef(c.getRefName()))
-        .filter(Objects::nonNull)
-        .collect(toSet());
-  }
-
-  private void commitAccountConfig(
-      String message,
-      Repository allUsersRepo,
-      BatchRefUpdate batchRefUpdate,
-      AccountConfig accountConfig,
-      boolean allowEmptyCommit)
-      throws IOException {
-    try (MetaDataUpdate md = createMetaDataUpdate(message, allUsersRepo, batchRefUpdate)) {
-      md.setAllowEmpty(allowEmptyCommit);
-      accountConfig.commit(md);
-    }
-  }
-
-  private RevCommit commitExternalIdUpdates(
-      String message, Repository allUsersRepo, BatchRefUpdate batchRefUpdate) throws IOException {
-    try (MetaDataUpdate md = createMetaDataUpdate(message, allUsersRepo, batchRefUpdate)) {
-      return externalIdNotes.commit(md);
-    }
-  }
-
-  private MetaDataUpdate createMetaDataUpdate(
-      String message, Repository allUsersRepo, BatchRefUpdate batchRefUpdate) {
-    MetaDataUpdate metaDataUpdate =
-        metaDataUpdateInternalFactory.get().create(allUsersName, allUsersRepo, batchRefUpdate);
-    if (!message.endsWith("\n")) {
-      message = message + "\n";
-    }
-
-    metaDataUpdate.getCommitBuilder().setMessage(message);
-    metaDataUpdate.getCommitBuilder().setCommitter(committerIdent);
-    metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
-    return metaDataUpdate;
-  }
-
-  private static void doNothing() {}
-
-  @FunctionalInterface
-  private interface ExecutableUpdate {
-    UpdatedAccount execute(Repository allUsersRepo) throws IOException, ConfigInvalidException;
-  }
-
-  private class UpdatedAccount {
-    final String message;
-    final AccountConfig accountConfig;
-    final CachedPreferences defaultPreferences;
-    final boolean created;
-
-    UpdatedAccount(
-        String message,
-        AccountConfig accountConfig,
-        CachedPreferences defaultPreferences,
-        boolean created) {
-      checkState(!Strings.isNullOrEmpty(message), "message for account update must be set");
-      this.message = requireNonNull(message);
-      this.accountConfig = requireNonNull(accountConfig);
-      this.defaultPreferences = defaultPreferences;
-      this.created = created;
-    }
-
-    Optional<AccountState> getAccountState() throws IOException {
-      return AccountState.fromAccountConfig(
-          externalIds, accountConfig, externalIdNotes, defaultPreferences);
-    }
+  private static PersonIdent createPersonIdent(
+      PersonIdent serverIdent, Optional<IdentifiedUser> user) {
+    return user.isPresent() ? user.get().newCommitterIdent(serverIdent) : serverIdent;
   }
 }
diff --git a/java/com/google/gerrit/server/account/CachedAccountDetails.java b/java/com/google/gerrit/server/account/CachedAccountDetails.java
index 2ab6174..e167a23 100644
--- a/java/com/google/gerrit/server/account/CachedAccountDetails.java
+++ b/java/com/google/gerrit/server/account/CachedAccountDetails.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.config.CachedPreferences;
 import java.time.Instant;
 import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 
 /** Details of an account that are cached persistently in {@link AccountCache}. */
@@ -40,21 +41,21 @@
 public abstract class CachedAccountDetails {
   @AutoValue
   public abstract static class Key {
-    static Key create(Account.Id accountId, ObjectId id) {
+    public static Key create(Account.Id accountId, ObjectId id) {
       return new AutoValue_CachedAccountDetails_Key(accountId, id.copy());
     }
 
     /** Identifier of the account. */
-    abstract Account.Id accountId();
+    public abstract Account.Id accountId();
 
     /**
      * Git revision at which the account was loaded. Corresponds to a revision on the account ref
      * ({@code refs/users/<sharded-id>}).
      */
-    abstract ObjectId id();
+    public abstract ObjectId id();
 
     /** Serializer used to read this entity from and write it to a persistent storage. */
-    enum Serializer implements CacheSerializer<Key> {
+    public enum Serializer implements CacheSerializer<Key> {
       INSTANCE;
 
       @Override
@@ -77,16 +78,17 @@
   }
 
   /** Essential attributes of the account, such as name or registration time. */
-  abstract Account account();
+  public abstract Account account();
 
   /** Projects that the user has configured to watch. */
-  abstract ImmutableMap<ProjectWatches.ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>>
+  public abstract ImmutableMap<
+          ProjectWatches.ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>>
       projectWatches();
 
   /** Preferences that this user has. Serialized as Git-config style string. */
-  abstract CachedPreferences preferences();
+  public abstract CachedPreferences preferences();
 
-  static CachedAccountDetails create(
+  public static CachedAccountDetails create(
       Account account,
       ImmutableMap<ProjectWatches.ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>>
           projectWatches,
@@ -95,7 +97,7 @@
   }
 
   /** Serializer used to read this entity from and write it to a persistent storage. */
-  enum Serializer implements CacheSerializer<CachedAccountDetails> {
+  public enum Serializer implements CacheSerializer<CachedAccountDetails> {
     INSTANCE;
 
     @Override
@@ -131,7 +133,11 @@
         serialized.addProjectWatchProto(proto);
       }
 
-      serialized.setUserPreferences(cachedAccountDetails.preferences().config());
+      Optional<Cache.CachedPreferencesProto> cachedPreferencesProto =
+          cachedAccountDetails.preferences().nonEmptyConfig();
+      if (cachedPreferencesProto.isPresent()) {
+        serialized.setUserPreferences(cachedPreferencesProto.get());
+      }
       return Protos.toByteArray(serialized.build());
     }
 
@@ -166,7 +172,7 @@
       return CachedAccountDetails.create(
           account,
           projectWatches.build(),
-          CachedPreferences.fromString(proto.getUserPreferences()));
+          CachedPreferences.fromCachedPreferencesProto(proto.getUserPreferences()));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/account/VersionedAccountDestinations.java b/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
index f1cf9fe..41a02a9 100644
--- a/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
+++ b/java/com/google/gerrit/server/account/VersionedAccountDestinations.java
@@ -15,20 +15,19 @@
 package com.google.gerrit.server.account;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.FileMode;
 
-/** User configured named destinations. */
+/** User or Group configured named destinations. */
 public class VersionedAccountDestinations extends VersionedMetaData {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static VersionedAccountDestinations forUser(Account.Id id) {
-    return new VersionedAccountDestinations(RefNames.refsUsers(id));
+  public static VersionedAccountDestinations forBranch(BranchNameKey branch) {
+    return new VersionedAccountDestinations(branch.branch());
   }
 
   private final String ref;
diff --git a/java/com/google/gerrit/server/account/VersionedAccountQueries.java b/java/com/google/gerrit/server/account/VersionedAccountQueries.java
index 5e63875..0269ccf 100644
--- a/java/com/google/gerrit/server/account/VersionedAccountQueries.java
+++ b/java/com/google/gerrit/server/account/VersionedAccountQueries.java
@@ -18,8 +18,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import java.io.IOException;
@@ -37,8 +36,8 @@
 public class VersionedAccountQueries extends VersionedMetaData {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static VersionedAccountQueries forUser(Account.Id id) {
-    return new VersionedAccountQueries(RefNames.refsUsers(id));
+  public static VersionedAccountQueries forBranch(BranchNameKey branch) {
+    return new VersionedAccountQueries(branch.branch());
   }
 
   private final String ref;
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
index 9196db8..1a6428c 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -27,7 +27,6 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.AuthType;
-import com.google.gerrit.git.ObjectIds;
 import java.io.Serializable;
 import java.util.Collection;
 import java.util.Locale;
@@ -100,10 +99,10 @@
 
   private static final long serialVersionUID = 1L;
 
-  static final String EXTERNAL_ID_SECTION = "externalId";
-  static final String ACCOUNT_ID_KEY = "accountId";
-  static final String EMAIL_KEY = "email";
-  static final String PASSWORD_KEY = "password";
+  public static final String EXTERNAL_ID_SECTION = "externalId";
+  public static final String ACCOUNT_ID_KEY = "accountId";
+  public static final String EMAIL_KEY = "email";
+  public static final String PASSWORD_KEY = "password";
 
   /**
    * Scheme used to label accounts created, when using the LDAP-based authentication types {@link
@@ -175,7 +174,6 @@
      *
      * @return the parsed external ID key
      */
-    @VisibleForTesting
     public static Key parse(String externalId, boolean isCaseInsensitive) {
       int c = externalId.indexOf(':');
       if (c < 1 || c >= externalId.length() - 1) {
@@ -254,7 +252,6 @@
     }
   }
 
-  @VisibleForTesting
   public static ExternalId create(
       Key key,
       Account.Id accountId,
@@ -294,15 +291,6 @@
     return key().isScheme(scheme);
   }
 
-  public byte[] toByteArray() {
-    checkState(blobId() != null, "Missing blobId in external ID %s", key().get());
-    byte[] b = new byte[2 * ObjectIds.STR_LEN + 1];
-    key().sha1().copyTo(b, 0);
-    b[ObjectIds.STR_LEN] = ':';
-    blobId().copyTo(b, ObjectIds.STR_LEN + 1);
-    return b;
-  }
-
   /**
    * For checking if two external IDs are equals the blobId is excluded and external IDs that have
    * different blob IDs but identical other fields are considered equal. This way an external ID
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
index fe8feac..a23e7bc 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.entities.Account;
 import java.io.IOException;
 import java.util.Optional;
-import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * Caches external IDs of all accounts. Note that the granularity is "revision" only, so each update
@@ -35,8 +34,6 @@
 
   ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException;
 
-  ImmutableSet<ExternalId> byAccount(Account.Id accountId, ObjectId rev) throws IOException;
-
   ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException;
 
   ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException;
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java b/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
index b16f73f..d226565 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
@@ -14,33 +14,10 @@
 
 package com.google.gerrit.server.account.externalids;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.Objects.requireNonNull;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.Iterables;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.server.account.HashedPassword;
-import com.google.gerrit.server.config.AuthConfig;
-import java.util.Set;
-import javax.inject.Inject;
-import javax.inject.Singleton;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
 
-@Singleton
-public class ExternalIdFactory {
-  private final ExternalIdKeyFactory externalIdKeyFactory;
-  private AuthConfig authConfig;
-
-  @Inject
-  public ExternalIdFactory(ExternalIdKeyFactory externalIdKeyFactory, AuthConfig authConfig) {
-    this.externalIdKeyFactory = externalIdKeyFactory;
-    this.authConfig = authConfig;
-  }
-
+public interface ExternalIdFactory {
   /**
    * Creates an external ID.
    *
@@ -50,9 +27,7 @@
    * @param accountId the ID of the account to which the external ID belongs
    * @return the created external ID
    */
-  public ExternalId create(String scheme, String id, Account.Id accountId) {
-    return create(externalIdKeyFactory.create(scheme, id), accountId, null, null);
-  }
+  ExternalId create(String scheme, String id, Account.Id accountId);
 
   /**
    * Creates an external ID.
@@ -65,14 +40,12 @@
    * @param hashedPassword the hashed password of the external ID, may be {@code null}
    * @return the created external ID
    */
-  public ExternalId create(
+  ExternalId create(
       String scheme,
       String id,
       Account.Id accountId,
       @Nullable String email,
-      @Nullable String hashedPassword) {
-    return create(externalIdKeyFactory.create(scheme, id), accountId, email, hashedPassword);
-  }
+      @Nullable String hashedPassword);
 
   /**
    * Creates an external ID.
@@ -81,9 +54,7 @@
    * @param accountId the ID of the account to which the external ID belongs
    * @return the created external ID
    */
-  public ExternalId create(ExternalId.Key key, Account.Id accountId) {
-    return create(key, accountId, null, null);
-  }
+  ExternalId create(ExternalId.Key key, Account.Id accountId);
 
   /**
    * Creates an external ID.
@@ -94,14 +65,11 @@
    * @param hashedPassword the hashed password of the external ID, may be {@code null}
    * @return the created external ID
    */
-  public ExternalId create(
+  ExternalId create(
       ExternalId.Key key,
       Account.Id accountId,
       @Nullable String email,
-      @Nullable String hashedPassword) {
-    return create(
-        key, accountId, Strings.emptyToNull(email), Strings.emptyToNull(hashedPassword), null);
-  }
+      @Nullable String hashedPassword);
 
   /**
    * Creates an external ID adding a hashed password computed from a plain password.
@@ -112,16 +80,11 @@
    * @param plainPassword the plain HTTP password, may be {@code null}
    * @return the created external ID
    */
-  public ExternalId createWithPassword(
+  ExternalId createWithPassword(
       ExternalId.Key key,
       Account.Id accountId,
       @Nullable String email,
-      @Nullable String plainPassword) {
-    plainPassword = Strings.emptyToNull(plainPassword);
-    String hashedPassword =
-        plainPassword != null ? HashedPassword.fromPassword(plainPassword).encode() : null;
-    return create(key, accountId, email, hashedPassword);
-  }
+      @Nullable String plainPassword);
 
   /**
    * Create a external ID for a username (scheme "username").
@@ -131,14 +94,7 @@
    * @param plainPassword the plain HTTP password, may be {@code null}
    * @return the created external ID
    */
-  public ExternalId createUsername(
-      String id, Account.Id accountId, @Nullable String plainPassword) {
-    return createWithPassword(
-        externalIdKeyFactory.create(ExternalId.SCHEME_USERNAME, id),
-        accountId,
-        null,
-        plainPassword);
-  }
+  ExternalId createUsername(String id, Account.Id accountId, @Nullable String plainPassword);
 
   /**
    * Creates an external ID with an email.
@@ -150,10 +106,8 @@
    * @param email the email of the external ID, may be {@code null}
    * @return the created external ID
    */
-  public ExternalId createWithEmail(
-      String scheme, String id, Account.Id accountId, @Nullable String email) {
-    return createWithEmail(externalIdKeyFactory.create(scheme, id), accountId, email);
-  }
+  ExternalId createWithEmail(
+      String scheme, String id, Account.Id accountId, @Nullable String email);
 
   /**
    * Creates an external ID with an email.
@@ -163,10 +117,7 @@
    * @param email the email of the external ID, may be {@code null}
    * @return the created external ID
    */
-  public ExternalId createWithEmail(
-      ExternalId.Key key, Account.Id accountId, @Nullable String email) {
-    return create(key, accountId, Strings.emptyToNull(email), null);
-  }
+  ExternalId createWithEmail(ExternalId.Key key, Account.Id accountId, @Nullable String email);
 
   /**
    * Creates an external ID using the `mailto`-scheme.
@@ -175,162 +126,5 @@
    * @param email the email of the external ID, may be {@code null}
    * @return the created external ID
    */
-  public ExternalId createEmail(Account.Id accountId, String email) {
-    return createWithEmail(ExternalId.SCHEME_MAILTO, email, accountId, requireNonNull(email));
-  }
-
-  ExternalId create(ExternalId extId, @Nullable ObjectId blobId) {
-    return create(extId.key(), extId.accountId(), extId.email(), extId.password(), blobId);
-  }
-
-  /**
-   * Creates an external ID.
-   *
-   * @param key the external Id key
-   * @param accountId the ID of the account to which the external ID belongs
-   * @param email the email of the external ID, may be {@code null}
-   * @param hashedPassword the hashed password of the external ID, may be {@code null}
-   * @param blobId the ID of the note blob in the external IDs branch that stores this external ID.
-   *     {@code null} if the external ID was created in code and is not yet stored in Git.
-   * @return the created external ID
-   */
-  public ExternalId create(
-      ExternalId.Key key,
-      Account.Id accountId,
-      @Nullable String email,
-      @Nullable String hashedPassword,
-      @Nullable ObjectId blobId) {
-    return ExternalId.create(
-        key, accountId, Strings.emptyToNull(email), Strings.emptyToNull(hashedPassword), blobId);
-  }
-
-  /**
-   * Parses an external ID from a byte array that contains the external ID as a Git config file
-   * text.
-   *
-   * <p>The Git config must have exactly one externalId subsection with an accountId and optionally
-   * email and password:
-   *
-   * <pre>
-   * [externalId "username:jdoe"]
-   *   accountId = 1003407
-   *   email = jdoe@example.com
-   *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
-   * </pre>
-   *
-   * @param noteId the SHA-1 sum of the external ID used as the note's ID
-   * @param raw a byte array that contains the external ID as a Git config file text.
-   * @param blobId the ID of the note blob in the external IDs branch that stores this external ID.
-   *     {@code null} if the external ID was created in code and is not yet stored in Git.
-   * @return the parsed external ID
-   */
-  public ExternalId parse(String noteId, byte[] raw, ObjectId blobId)
-      throws ConfigInvalidException {
-    requireNonNull(blobId);
-
-    Config externalIdConfig = new Config();
-    try {
-      externalIdConfig.fromText(new String(raw, UTF_8));
-    } catch (ConfigInvalidException e) {
-      throw invalidConfig(noteId, e.getMessage());
-    }
-
-    Set<String> externalIdKeys = externalIdConfig.getSubsections(ExternalId.EXTERNAL_ID_SECTION);
-    if (externalIdKeys.size() != 1) {
-      throw invalidConfig(
-          noteId,
-          String.format(
-              "Expected exactly 1 '%s' section, found %d",
-              ExternalId.EXTERNAL_ID_SECTION, externalIdKeys.size()));
-    }
-
-    String externalIdKeyStr = Iterables.getOnlyElement(externalIdKeys);
-    ExternalId.Key externalIdKey = externalIdKeyFactory.parse(externalIdKeyStr);
-    if (externalIdKey == null) {
-      throw invalidConfig(noteId, String.format("External ID %s is invalid", externalIdKeyStr));
-    }
-
-    if (!externalIdKey.sha1().getName().equals(noteId)) {
-      if (!authConfig.isUserNameCaseInsensitiveMigrationMode()) {
-        throw invalidConfig(
-            noteId,
-            String.format(
-                "SHA1 of external ID '%s' does not match note ID '%s'", externalIdKeyStr, noteId));
-      }
-
-      if (!externalIdKey.caseSensitiveSha1().getName().equals(noteId)) {
-        throw invalidConfig(
-            noteId,
-            String.format(
-                "Neither case sensitive nor case insensitive SHA1 of external ID '%s' match note ID"
-                    + " '%s'",
-                externalIdKeyStr, noteId));
-      }
-      externalIdKey =
-          externalIdKeyFactory.create(externalIdKey.scheme(), externalIdKey.id(), false);
-    }
-
-    String email =
-        externalIdConfig.getString(
-            ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.EMAIL_KEY);
-    String password =
-        externalIdConfig.getString(
-            ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.PASSWORD_KEY);
-    int accountId = readAccountId(noteId, externalIdConfig, externalIdKeyStr);
-
-    return create(
-        externalIdKey,
-        Account.id(accountId),
-        Strings.emptyToNull(email),
-        Strings.emptyToNull(password),
-        blobId);
-  }
-
-  private static int readAccountId(String noteId, Config externalIdConfig, String externalIdKeyStr)
-      throws ConfigInvalidException {
-    String accountIdStr =
-        externalIdConfig.getString(
-            ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.ACCOUNT_ID_KEY);
-    if (accountIdStr == null) {
-      throw invalidConfig(
-          noteId,
-          String.format(
-              "Value for '%s.%s.%s' is missing, expected account ID",
-              ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.ACCOUNT_ID_KEY));
-    }
-
-    try {
-      int accountId =
-          externalIdConfig.getInt(
-              ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.ACCOUNT_ID_KEY, -1);
-      if (accountId < 0) {
-        throw invalidConfig(
-            noteId,
-            String.format(
-                "Value %s for '%s.%s.%s' is invalid, expected account ID",
-                accountIdStr,
-                ExternalId.EXTERNAL_ID_SECTION,
-                externalIdKeyStr,
-                ExternalId.ACCOUNT_ID_KEY));
-      }
-      return accountId;
-    } catch (IllegalArgumentException e) {
-      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;
-    }
-  }
-
-  private static ConfigInvalidException invalidConfig(String noteId, String message) {
-    return new ConfigInvalidException(
-        String.format("Invalid external ID config for note '%s': %s", noteId, message));
-  }
+  ExternalId createEmail(Account.Id accountId, String email);
 }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java b/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
index da7b357..2d3e241 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
@@ -19,7 +19,6 @@
 public class ExternalIdModule extends AbstractModule {
   @Override
   protected void configure() {
-    bind(ExternalIdFactory.class);
     bind(ExternalIdKeyFactory.class);
     bind(PasswordVerifier.class);
   }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdUpsertPreprocessor.java b/java/com/google/gerrit/server/account/externalids/ExternalIdUpsertPreprocessor.java
index c0697db..6d21072 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdUpsertPreprocessor.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdUpsertPreprocessor.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account.externalids;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNotes;
 
 /**
  * This optional preprocessor is called in {@link ExternalIdNotes} before an update is committed.
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIds.java b/java/com/google/gerrit/server/account/externalids/ExternalIds.java
index 9450ff5..0755a6d 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIds.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIds.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 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,102 +14,33 @@
 
 package com.google.gerrit.server.account.externalids;
 
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
-
 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;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
 
-/**
- * Class to access external IDs.
- *
- * <p>The external IDs are either read from NoteDb or retrieved from the cache.
- */
-@Singleton
-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,
-      ExternalIdKeyFactory externalIdKeyFactory,
-      AuthConfig authConfig) {
-    this.externalIdReader = externalIdReader;
-    this.externalIdCache = externalIdCache;
-    this.externalIdKeyFactory = externalIdKeyFactory;
-    this.authConfig = authConfig;
-  }
-
+public interface ExternalIds {
   /** Returns all external IDs. */
-  public ImmutableSet<ExternalId> all() throws IOException, ConfigInvalidException {
-    return externalIdReader.all();
-  }
-
-  /** Returns all external IDs from the specified revision of the refs/meta/external-ids branch. */
-  public ImmutableSet<ExternalId> all(ObjectId rev) throws IOException, ConfigInvalidException {
-    return externalIdReader.all(rev);
-  }
+  ImmutableSet<ExternalId> all() throws IOException, ConfigInvalidException;
 
   /** Returns the specified external ID. */
-  public Optional<ExternalId> get(ExternalId.Key key) throws IOException {
-    Optional<ExternalId> externalId = Optional.empty();
-    if (authConfig.isUserNameCaseInsensitiveMigrationMode()) {
-      externalId =
-          externalIdCache.byKey(externalIdKeyFactory.create(key.scheme(), key.id(), false));
-    }
-    if (!externalId.isPresent()) {
-      externalId = externalIdCache.byKey(key);
-    }
-    return externalId;
-  }
-
-  /** Returns the specified external ID from the given revision. */
-  public Optional<ExternalId> get(ExternalId.Key key, ObjectId rev)
-      throws IOException, ConfigInvalidException {
-    return externalIdReader.get(key, rev);
-  }
+  Optional<ExternalId> get(ExternalId.Key key) throws IOException;
 
   /** Returns the external IDs of the specified account. */
-  public ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException {
-    return externalIdCache.byAccount(accountId);
-  }
+  ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException;
 
-  /** Returns the external IDs of the specified account that have the given scheme. */
-  public ImmutableSet<ExternalId> byAccount(Account.Id accountId, String scheme)
-      throws IOException {
-    return byAccount(accountId).stream()
-        .filter(e -> e.key().isScheme(scheme))
-        .collect(toImmutableSet());
-  }
-
-  /** Returns the external IDs of the specified account. */
-  public ImmutableSet<ExternalId> byAccount(Account.Id accountId, ObjectId rev) throws IOException {
-    return externalIdCache.byAccount(accountId, rev);
-  }
-
-  /** Returns the external IDs of the specified account that have the given scheme. */
-  public ImmutableSet<ExternalId> byAccount(Account.Id accountId, String scheme, ObjectId rev)
-      throws IOException {
-    return byAccount(accountId, rev).stream()
-        .filter(e -> e.key().isScheme(scheme))
-        .collect(toImmutableSet());
-  }
+  /**
+   * Returns the external IDs of the specified account that have the given scheme.
+   *
+   * <p>Callers to this method should care about accuracy rather than latency. For better latency
+   * performance, call {@link ExternalIdCache#byAccount} directly.
+   */
+  ImmutableSet<ExternalId> byAccount(Account.Id accountId, String scheme) throws IOException;
 
   /** Returns all external IDs by account. */
-  public ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException {
-    return externalIdCache.allByAccount();
-  }
+  ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException;
 
   /**
    * Returns the external ID with the given email.
@@ -117,16 +48,10 @@
    * <p>Each email should belong to a single external ID only. This means if more than one external
    * ID is returned there is an inconsistency in the external IDs.
    *
-   * <p>The external IDs are retrieved from the external ID cache. Each access to the external ID
-   * cache requires reading the SHA1 of the refs/meta/external-ids branch. If external IDs for
-   * multiple emails are needed it is more efficient to use {@link #byEmails(String...)} as this
-   * method reads the SHA1 of the refs/meta/external-ids branch only once (and not once per email).
-   *
-   * @see #byEmails(String...)
+   * <p>Callers to this method should care about accuracy rather than latency. For better latency
+   * performance, call {@link ExternalIdCache#byEmail(String)} directly.
    */
-  public ImmutableSet<ExternalId> byEmail(String email) throws IOException {
-    return externalIdCache.byEmail(email);
-  }
+  ImmutableSet<ExternalId> byEmail(String email) throws IOException;
 
   /**
    * Returns the external IDs for the given emails.
@@ -134,20 +59,10 @@
    * <p>Each email should belong to a single external ID only. This means if more than one external
    * ID for an email is returned there is an inconsistency in the external IDs.
    *
-   * <p>The external IDs are retrieved from the external ID cache. Each access to the external ID
-   * cache requires reading the SHA1 of the refs/meta/external-ids branch. If external IDs for
-   * multiple emails are needed it is more efficient to use this method instead of {@link
-   * #byEmail(String)} as this method reads the SHA1 of the refs/meta/external-ids branch only once
-   * (and not once per email).
+   * <p>Callers to this method should care about accuracy rather than latency. For better latency
+   * performance, call {@link ExternalIdCache#byEmails(String...)} directly.
    *
    * @see #byEmail(String)
    */
-  public ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException {
-    return externalIdCache.byEmails(emails);
-  }
-
-  /** Returns all external IDs by email. */
-  public ImmutableSetMultimap<String, ExternalId> allByEmail() throws IOException {
-    return externalIdCache.allByEmail();
-  }
+  ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException;
 }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
index 4e67e3d..56115b2 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
@@ -14,140 +14,17 @@
 
 package com.google.gerrit.server.account.externalids;
 
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
-import static java.util.stream.Collectors.joining;
-
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
 import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.HashedPassword;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.Note;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevWalk;
 
-@Singleton
-public class ExternalIdsConsistencyChecker {
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsers;
-  private final AccountCache accountCache;
-  private final OutgoingEmailValidator validator;
-  private final ExternalIdFactory externalIdFactory;
+public interface ExternalIdsConsistencyChecker {
+  List<ConsistencyCheckInfo.ConsistencyProblemInfo> check(AccountCache accountCache)
+      throws IOException, ConfigInvalidException;
 
-  @Inject
-  ExternalIdsConsistencyChecker(
-      GitRepositoryManager repoManager,
-      AllUsersName allUsers,
-      AccountCache accountCache,
-      OutgoingEmailValidator validator,
-      ExternalIdFactory externalIdFactory) {
-    this.repoManager = repoManager;
-    this.allUsers = allUsers;
-    this.accountCache = accountCache;
-    this.validator = validator;
-    this.externalIdFactory = externalIdFactory;
-  }
-
-  public List<ConsistencyProblemInfo> check() throws IOException, ConfigInvalidException {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      return check(ExternalIdNotes.loadReadOnly(allUsers, repo, 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, false));
-    }
-  }
-
-  private List<ConsistencyProblemInfo> check(ExternalIdNotes extIdNotes) throws IOException {
-    List<ConsistencyProblemInfo> problems = new ArrayList<>();
-
-    ListMultimap<String, ExternalId> emails = MultimapBuilder.hashKeys().arrayListValues().build();
-
-    try (RevWalk rw = new RevWalk(extIdNotes.getRepository())) {
-      NoteMap noteMap = extIdNotes.getNoteMap();
-      for (Note note : noteMap) {
-        byte[] raw = ExternalIdNotes.readNoteData(rw, note.getData());
-        try {
-          ExternalId extId = externalIdFactory.parse(note.getName(), raw, note.getData());
-          problems.addAll(validateExternalId(extId));
-
-          if (extId.email() != null) {
-            String email = extId.email();
-            if (emails.get(email).stream()
-                .noneMatch(e -> e.accountId().get() == extId.accountId().get())) {
-              emails.put(email, extId);
-            }
-          }
-        } catch (ConfigInvalidException e) {
-          addError(String.format(e.getMessage()), problems);
-        }
-      }
-    }
-
-    emails.asMap().entrySet().stream()
-        .filter(e -> e.getValue().size() > 1)
-        .forEach(
-            e ->
-                addError(
-                    String.format(
-                        "Email '%s' is not unique, it's used by the following external IDs: %s",
-                        e.getKey(),
-                        e.getValue().stream()
-                            .map(k -> "'" + k.key().get() + "'")
-                            .sorted()
-                            .collect(joining(", "))),
-                    problems));
-
-    return problems;
-  }
-
-  private List<ConsistencyProblemInfo> validateExternalId(ExternalId extId) {
-    List<ConsistencyProblemInfo> problems = new ArrayList<>();
-
-    if (!accountCache.get(extId.accountId()).isPresent()) {
-      addError(
-          String.format(
-              "External ID '%s' belongs to account that doesn't exist: %s",
-              extId.key().get(), extId.accountId().get()),
-          problems);
-    }
-
-    if (extId.email() != null && !validator.isValid(extId.email())) {
-      addError(
-          String.format(
-              "External ID '%s' has an invalid email: %s", extId.key().get(), extId.email()),
-          problems);
-    }
-
-    if (extId.password() != null && extId.isScheme(SCHEME_USERNAME)) {
-      try {
-        HashedPassword.decode(extId.password());
-      } catch (HashedPassword.DecoderException e) {
-        addError(
-            String.format(
-                "External ID '%s' has an invalid password: %s", extId.key().get(), e.getMessage()),
-            problems);
-      }
-    }
-
-    return problems;
-  }
-
-  private static void addError(String error, List<ConsistencyProblemInfo> problems) {
-    problems.add(new ConsistencyProblemInfo(ConsistencyProblemInfo.Status.ERROR, error));
-  }
+  List<ConsistencyCheckInfo.ConsistencyProblemInfo> check(AccountCache accountCache, ObjectId rev)
+      throws IOException, ConfigInvalidException;
 }
diff --git a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/AllExternalIds.java
similarity index 96%
rename from java/com/google/gerrit/server/account/externalids/AllExternalIds.java
rename to java/com/google/gerrit/server/account/externalids/storage/notedb/AllExternalIds.java
index 14aa368..8660b01 100644
--- a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/AllExternalIds.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.account.externalids;
+package com.google.gerrit.server.account.externalids.storage.notedb;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto;
 import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto.ExternalIdProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
diff --git a/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/DisabledExternalIdCache.java
similarity index 88%
rename from java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
rename to java/com/google/gerrit/server/account/externalids/storage/notedb/DisabledExternalIdCache.java
index 2d1ec1a..8e53277 100644
--- a/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/DisabledExternalIdCache.java
@@ -12,16 +12,17 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.account.externalids;
+package com.google.gerrit.server.account.externalids.storage.notedb;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
 import com.google.inject.AbstractModule;
 import com.google.inject.Module;
 import java.io.IOException;
 import java.util.Optional;
-import org.eclipse.jgit.lib.ObjectId;
 
 public class DisabledExternalIdCache implements ExternalIdCache {
   public static Module module() {
@@ -45,11 +46,6 @@
   }
 
   @Override
-  public ImmutableSet<ExternalId> byAccount(Account.Id accountId, ObjectId rev) {
-    throw new UnsupportedOperationException();
-  }
-
-  @Override
   public ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException {
     throw new UnsupportedOperationException();
   }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCacheImpl.java
similarity index 86%
rename from java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
rename to java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCacheImpl.java
index af8e19f..dbfe205 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCacheImpl.java
@@ -12,12 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.account.externalids;
+package com.google.gerrit.server.account.externalids.storage.notedb;
 
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
@@ -59,8 +61,7 @@
     return get().byAccount().get(accountId);
   }
 
-  @Override
-  public ImmutableSet<ExternalId> byAccount(Account.Id accountId, ObjectId rev) throws IOException {
+  ImmutableSet<ExternalId> byAccount(Account.Id accountId, ObjectId rev) throws IOException {
     return get(rev).byAccount().get(accountId);
   }
 
@@ -69,6 +70,14 @@
     return get().byAccount();
   }
 
+  /**
+   * Each access to the external ID cache requires reading the SHA1 of the refs/meta/external-ids
+   * branch. If external IDs for multiple emails are needed it is more efficient to use {@link
+   * #byEmails(String...)} as this method reads the SHA1 of the refs/meta/external-ids branch only
+   * once (and not once per email).
+   *
+   * @see #byEmails(String...)
+   */
   @Override
   public ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException {
     AllExternalIds allExternalIds = get();
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCacheLoader.java
similarity index 97%
rename from java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
rename to java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCacheLoader.java
index da58bbb..f2a5885 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCacheLoader.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.account.externalids;
+package com.google.gerrit.server.account.externalids.storage.notedb;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.CharMatcher;
@@ -29,6 +29,7 @@
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -73,7 +74,7 @@
   private final Counter1<Boolean> reloadCounter;
   private final Timer0 reloadDifferential;
   private final boolean isPersistentCache;
-  private final ExternalIdFactory externalIdFactory;
+  private final ExternalIdFactoryNoteDbImpl externalIdFactory;
 
   @Inject
   ExternalIdCacheLoader(
@@ -83,7 +84,7 @@
       @Named(ExternalIdCacheImpl.CACHE_NAME) Cache<ObjectId, AllExternalIds> externalIdCache,
       MetricMaker metricMaker,
       @GerritServerConfig Config config,
-      ExternalIdFactory externalIdFactory) {
+      ExternalIdFactoryNoteDbImpl externalIdFactory) {
     this.externalIdReader = externalIdReader;
     this.externalIdCache = externalIdCache;
     this.gitRepositoryManager = gitRepositoryManager;
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheModule.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCacheModule.java
similarity index 93%
rename from java/com/google/gerrit/server/account/externalids/ExternalIdCacheModule.java
rename to java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCacheModule.java
index 1873ea0..aca0e1a 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheModule.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCacheModule.java
@@ -12,8 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.account.externalids;
+package com.google.gerrit.server.account.externalids.storage.notedb;
 
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.serialize.ObjectIdCacheSerializer;
 import com.google.inject.TypeLiteral;
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCaseSensitivityMigrator.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCaseSensitivityMigrator.java
similarity index 87%
rename from java/com/google/gerrit/server/account/externalids/ExternalIdCaseSensitivityMigrator.java
rename to java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCaseSensitivityMigrator.java
index a6ee366c..4e1323e 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCaseSensitivityMigrator.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdCaseSensitivityMigrator.java
@@ -12,13 +12,16 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.account.externalids;
+package com.google.gerrit.server.account.externalids.storage.notedb;
 
 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.account.externalids.DuplicateExternalIdKeyException;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -51,14 +54,14 @@
         @Assisted("dryRun") Boolean dryRun);
   }
 
-  private GitRepositoryManager repoManager;
-  private AllUsersName allUsersName;
-  private Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory;
-  private ExternalIdNotes.FactoryNoReindex externalIdNotesFactory;
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory;
+  private final ExternalIdNotes.FactoryNoReindex externalIdNotesFactory;
 
-  private ExternalIdFactory externalIdFactory;
-  private Boolean isUserNameCaseInsensitive;
-  private Boolean dryRun;
+  private final ExternalIdFactoryNoteDbImpl externalIdFactory;
+  private final Boolean isUserNameCaseInsensitive;
+  private final Boolean dryRun;
 
   @Inject
   public ExternalIdCaseSensitivityMigrator(
@@ -66,7 +69,7 @@
       AllUsersName allUsersName,
       Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory,
       ExternalIdNotes.FactoryNoReindex externalIdNotesFactory,
-      ExternalIdFactory externalIdFactory,
+      ExternalIdFactoryNoteDbImpl externalIdFactory,
       @Assisted("isUserNameCaseInsensitive") Boolean isUserNameCaseInsensitive,
       @Assisted("dryRun") Boolean dryRun) {
     this.repoManager = repoManager;
diff --git a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdFactoryNoteDbImpl.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdFactoryNoteDbImpl.java
new file mode 100644
index 0000000..3462c76
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdFactoryNoteDbImpl.java
@@ -0,0 +1,273 @@
+// 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.account.externalids.storage.notedb;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.account.HashedPassword;
+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.config.AuthConfig;
+import java.util.Set;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+
+@Singleton
+public class ExternalIdFactoryNoteDbImpl implements ExternalIdFactory {
+  private final ExternalIdKeyFactory externalIdKeyFactory;
+  private AuthConfig authConfig;
+
+  @Inject
+  @VisibleForTesting
+  public ExternalIdFactoryNoteDbImpl(
+      ExternalIdKeyFactory externalIdKeyFactory, AuthConfig authConfig) {
+    this.externalIdKeyFactory = externalIdKeyFactory;
+    this.authConfig = authConfig;
+  }
+
+  @Override
+  public ExternalId create(String scheme, String id, Account.Id accountId) {
+    return create(externalIdKeyFactory.create(scheme, id), accountId, null, null);
+  }
+
+  @Override
+  public ExternalId create(
+      String scheme,
+      String id,
+      Account.Id accountId,
+      @Nullable String email,
+      @Nullable String hashedPassword) {
+    return create(externalIdKeyFactory.create(scheme, id), accountId, email, hashedPassword);
+  }
+
+  @Override
+  public ExternalId create(ExternalId.Key key, Account.Id accountId) {
+    return create(key, accountId, null, null);
+  }
+
+  @Override
+  public ExternalId create(
+      ExternalId.Key key,
+      Account.Id accountId,
+      @Nullable String email,
+      @Nullable String hashedPassword) {
+    return create(
+        key, accountId, Strings.emptyToNull(email), Strings.emptyToNull(hashedPassword), null);
+  }
+
+  ExternalId create(ExternalId extId, @Nullable ObjectId blobId) {
+    return create(extId.key(), extId.accountId(), extId.email(), extId.password(), blobId);
+  }
+
+  /**
+   * Creates an external ID.
+   *
+   * @param key the external Id key
+   * @param accountId the ID of the account to which the external ID belongs
+   * @param email the email of the external ID, may be {@code null}
+   * @param hashedPassword the hashed password of the external ID, may be {@code null}
+   * @param blobId the ID of the note blob in the external IDs branch that stores this external ID.
+   *     {@code null} if the external ID was created in code and is not yet stored in Git.
+   * @return the created external ID
+   */
+  public ExternalId create(
+      ExternalId.Key key,
+      Account.Id accountId,
+      @Nullable String email,
+      @Nullable String hashedPassword,
+      @Nullable ObjectId blobId) {
+    return ExternalId.create(
+        key, accountId, Strings.emptyToNull(email), Strings.emptyToNull(hashedPassword), blobId);
+  }
+
+  @Override
+  public ExternalId createWithPassword(
+      ExternalId.Key key,
+      Account.Id accountId,
+      @Nullable String email,
+      @Nullable String plainPassword) {
+    plainPassword = Strings.emptyToNull(plainPassword);
+    String hashedPassword =
+        plainPassword != null ? HashedPassword.fromPassword(plainPassword).encode() : null;
+    return create(key, accountId, email, hashedPassword);
+  }
+
+  @Override
+  public ExternalId createUsername(
+      String id, Account.Id accountId, @Nullable String plainPassword) {
+    return createWithPassword(
+        externalIdKeyFactory.create(ExternalId.SCHEME_USERNAME, id),
+        accountId,
+        null,
+        plainPassword);
+  }
+
+  @Override
+  public ExternalId createWithEmail(
+      String scheme, String id, Account.Id accountId, @Nullable String email) {
+    return createWithEmail(externalIdKeyFactory.create(scheme, id), accountId, email);
+  }
+
+  @Override
+  public ExternalId createWithEmail(
+      ExternalId.Key key, Account.Id accountId, @Nullable String email) {
+    return create(key, accountId, Strings.emptyToNull(email), null);
+  }
+
+  @Override
+  public ExternalId createEmail(Account.Id accountId, String email) {
+    return createWithEmail(ExternalId.SCHEME_MAILTO, email, accountId, requireNonNull(email));
+  }
+
+  /**
+   * Parses an external ID from a byte array that contains the external ID as a Git config file
+   * text.
+   *
+   * <p>The Git config must have exactly one externalId subsection with an accountId and optionally
+   * email and password:
+   *
+   * <pre>
+   * [externalId "username:jdoe"]
+   *   accountId = 1003407
+   *   email = jdoe@example.com
+   *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
+   * </pre>
+   *
+   * @param noteId the SHA-1 sum of the external ID used as the note's ID
+   * @param raw a byte array that contains the external ID as a Git config file text.
+   * @param blobId the ID of the note blob in the external IDs branch that stores this external ID.
+   *     {@code null} if the external ID was created in code and is not yet stored in Git.
+   * @return the parsed external ID
+   */
+  public ExternalId parse(String noteId, byte[] raw, ObjectId blobId)
+      throws ConfigInvalidException {
+    requireNonNull(blobId);
+
+    Config externalIdConfig = new Config();
+    try {
+      externalIdConfig.fromText(new String(raw, UTF_8));
+    } catch (ConfigInvalidException e) {
+      throw invalidConfig(noteId, e.getMessage());
+    }
+
+    Set<String> externalIdKeys = externalIdConfig.getSubsections(ExternalId.EXTERNAL_ID_SECTION);
+    if (externalIdKeys.size() != 1) {
+      throw invalidConfig(
+          noteId,
+          String.format(
+              "Expected exactly 1 '%s' section, found %d",
+              ExternalId.EXTERNAL_ID_SECTION, externalIdKeys.size()));
+    }
+
+    String externalIdKeyStr = Iterables.getOnlyElement(externalIdKeys);
+    ExternalId.Key externalIdKey = externalIdKeyFactory.parse(externalIdKeyStr);
+    if (externalIdKey == null) {
+      throw invalidConfig(noteId, String.format("External ID %s is invalid", externalIdKeyStr));
+    }
+
+    if (!externalIdKey.sha1().getName().equals(noteId)) {
+      if (!authConfig.isUserNameCaseInsensitiveMigrationMode()) {
+        throw invalidConfig(
+            noteId,
+            String.format(
+                "SHA1 of external ID '%s' does not match note ID '%s'", externalIdKeyStr, noteId));
+      }
+
+      if (!externalIdKey.caseSensitiveSha1().getName().equals(noteId)) {
+        throw invalidConfig(
+            noteId,
+            String.format(
+                "Neither case sensitive nor case insensitive SHA1 of external ID '%s' match note ID"
+                    + " '%s'",
+                externalIdKeyStr, noteId));
+      }
+      externalIdKey =
+          externalIdKeyFactory.create(externalIdKey.scheme(), externalIdKey.id(), false);
+    }
+
+    String email =
+        externalIdConfig.getString(
+            ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.EMAIL_KEY);
+    String password =
+        externalIdConfig.getString(
+            ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.PASSWORD_KEY);
+    int accountId = readAccountId(noteId, externalIdConfig, externalIdKeyStr);
+
+    return create(
+        externalIdKey,
+        Account.id(accountId),
+        Strings.emptyToNull(email),
+        Strings.emptyToNull(password),
+        blobId);
+  }
+
+  private static int readAccountId(String noteId, Config externalIdConfig, String externalIdKeyStr)
+      throws ConfigInvalidException {
+    String accountIdStr =
+        externalIdConfig.getString(
+            ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.ACCOUNT_ID_KEY);
+    if (accountIdStr == null) {
+      throw invalidConfig(
+          noteId,
+          String.format(
+              "Value for '%s.%s.%s' is missing, expected account ID",
+              ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.ACCOUNT_ID_KEY));
+    }
+
+    try {
+      int accountId =
+          externalIdConfig.getInt(
+              ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.ACCOUNT_ID_KEY, -1);
+      if (accountId < 0) {
+        throw invalidConfig(
+            noteId,
+            String.format(
+                "Value %s for '%s.%s.%s' is invalid, expected account ID",
+                accountIdStr,
+                ExternalId.EXTERNAL_ID_SECTION,
+                externalIdKeyStr,
+                ExternalId.ACCOUNT_ID_KEY));
+      }
+      return accountId;
+    } catch (IllegalArgumentException e) {
+      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;
+    }
+  }
+
+  private static ConfigInvalidException invalidConfig(String noteId, String message) {
+    return new ConfigInvalidException(
+        String.format("Invalid external ID config for note '%s': %s", noteId, message));
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNoteDbReadStorageModule.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNoteDbReadStorageModule.java
new file mode 100644
index 0000000..6fa919d
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNoteDbReadStorageModule.java
@@ -0,0 +1,32 @@
+// 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.account.externalids.storage.notedb;
+
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker;
+import com.google.inject.AbstractModule;
+import com.google.inject.Singleton;
+
+public class ExternalIdNoteDbReadStorageModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    bind(ExternalIdFactory.class).to(ExternalIdFactoryNoteDbImpl.class).in(Singleton.class);
+    bind(ExternalIds.class).to(ExternalIdsNoteDbImpl.class).in(Singleton.class);
+    bind(ExternalIdsConsistencyChecker.class)
+        .to(ExternalIdsConsistencyCheckerNoteDbImpl.class)
+        .in(Singleton.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNoteDbWriteStorageModule.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNoteDbWriteStorageModule.java
new file mode 100644
index 0000000..70b5d58
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNoteDbWriteStorageModule.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.account.externalids.storage.notedb;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.server.account.externalids.ExternalIdUpsertPreprocessor;
+import com.google.inject.AbstractModule;
+
+public class ExternalIdNoteDbWriteStorageModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    DynamicMap.mapOf(binder(), ExternalIdUpsertPreprocessor.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNotes.java
similarity index 97%
rename from java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
rename to java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNotes.java
index 48c403c..5346252 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNotes.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.account.externalids;
+package com.google.gerrit.server.account.externalids.storage.notedb;
 
 import static com.google.common.base.Preconditions.checkState;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -26,6 +26,7 @@
 import com.google.common.collect.Sets;
 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.RefNames;
@@ -36,6 +37,11 @@
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdCache;
+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.AuthConfig;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -101,7 +107,7 @@
     protected final MetricMaker metricMaker;
     protected final AllUsersName allUsersName;
     protected final DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors;
-    protected final ExternalIdFactory externalIdFactory;
+    protected final ExternalIdFactoryNoteDbImpl externalIdFactory;
     protected final AuthConfig authConfig;
 
     protected ExternalIdNotesLoader(
@@ -109,7 +115,7 @@
         MetricMaker metricMaker,
         AllUsersName allUsersName,
         DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors,
-        ExternalIdFactory externalIdFactory,
+        ExternalIdFactoryNoteDbImpl externalIdFactory,
         AuthConfig authConfig) {
       this.externalIdCache = externalIdCache;
       this.metricMaker = metricMaker;
@@ -197,7 +203,7 @@
         MetricMaker metricMaker,
         AllUsersName allUsersName,
         DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors,
-        ExternalIdFactory externalIdFactory,
+        ExternalIdFactoryNoteDbImpl externalIdFactory,
         AuthConfig authConfig) {
       super(
           externalIdCache,
@@ -250,7 +256,7 @@
         MetricMaker metricMaker,
         AllUsersName allUsersName,
         DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors,
-        ExternalIdFactory externalIdFactory,
+        ExternalIdFactoryNoteDbImpl externalIdFactory,
         AuthConfig authConfig) {
       super(
           externalIdCache,
@@ -309,7 +315,7 @@
       AllUsersName allUsersName,
       Repository allUsersRepo,
       @Nullable ObjectId rev,
-      ExternalIdFactory externalIdFactory,
+      ExternalIdFactoryNoteDbImpl externalIdFactory,
       boolean isUserNameCaseInsensitiveMigrationMode)
       throws IOException, ConfigInvalidException {
     return new ExternalIdNotes(
@@ -337,7 +343,7 @@
   public static ExternalIdNotes load(
       AllUsersName allUsersName,
       Repository allUsersRepo,
-      ExternalIdFactory externalIdFactory,
+      ExternalIdFactoryNoteDbImpl externalIdFactory,
       boolean isUserNameCaseInsensitiveMigrationMode)
       throws IOException, ConfigInvalidException {
     return new ExternalIdNotes(
@@ -356,7 +362,7 @@
   private final Repository repo;
   private final DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors;
   private final CallerFinder callerFinder;
-  private final ExternalIdFactory externalIdFactory;
+  private final ExternalIdFactoryNoteDbImpl externalIdFactory;
 
   private NoteMap noteMap;
   private ObjectId oldRev;
@@ -400,7 +406,7 @@
       AllUsersName allUsersName,
       Repository allUsersRepo,
       DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors,
-      ExternalIdFactory externalIdFactory,
+      ExternalIdFactoryNoteDbImpl externalIdFactory,
       boolean isUserNameCaseInsensitiveMigrationMode) {
     this.updateCount =
         metricMaker.newCounter(
@@ -859,6 +865,7 @@
   }
 
   @Override
+  @CanIgnoreReturnValue
   public RevCommit commit(MetaDataUpdate update) throws IOException {
     oldRev = ObjectIds.copyOrZero(revision);
     RevCommit commit = super.commit(update);
@@ -916,6 +923,7 @@
    *
    * @return the ID of the account to which all specified external IDs belong.
    */
+  @CanIgnoreReturnValue
   public static Account.Id checkSameAccount(
       Iterable<ExternalId> extIds, @Nullable Account.Id accountId) {
     for (ExternalId extId : extIds) {
@@ -1091,6 +1099,7 @@
     final Set<ExternalId> added = new HashSet<>();
     final Set<ExternalId> removed = new HashSet<>();
 
+    @CanIgnoreReturnValue
     ExternalIdCacheUpdates add(Collection<ExternalId> extIds) {
       this.added.addAll(extIds);
       return this;
@@ -1100,6 +1109,7 @@
       return ImmutableSet.copyOf(added);
     }
 
+    @CanIgnoreReturnValue
     ExternalIdCacheUpdates remove(Collection<ExternalId> extIds) {
       this.removed.addAll(extIds);
       return this;
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdReader.java
similarity index 92%
rename from java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
rename to java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdReader.java
index fb7f6c4..dbaed04 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdReader.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.
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.account.externalids;
+package com.google.gerrit.server.account.externalids.storage.notedb;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableSet;
@@ -22,6 +22,7 @@
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -70,15 +71,16 @@
   private boolean failOnLoad = false;
   private final Timer0 readAllLatency;
   private final Timer0 readSingleLatency;
-  private final ExternalIdFactory externalIdFactory;
+  private final ExternalIdFactoryNoteDbImpl externalIdFactory;
   private final AuthConfig authConfig;
 
+  @VisibleForTesting
   @Inject
-  ExternalIdReader(
+  public ExternalIdReader(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
       MetricMaker metricMaker,
-      ExternalIdFactory externalIdFactory,
+      ExternalIdFactoryNoteDbImpl externalIdFactory,
       AuthConfig authConfig) {
     this.repoManager = repoManager;
     this.allUsersName = allUsersName;
@@ -109,7 +111,7 @@
     }
   }
 
-  ObjectId readRevision() throws IOException {
+  public ObjectId readRevision() throws IOException {
     try (Repository repo = repoManager.openRepository(allUsersName)) {
       return readRevision(repo);
     }
@@ -141,7 +143,8 @@
    *     empty
    * @return all external IDs that were read from the specified revision
    */
-  ImmutableSet<ExternalId> all(@Nullable ObjectId rev) throws IOException, ConfigInvalidException {
+  public ImmutableSet<ExternalId> all(@Nullable ObjectId rev)
+      throws IOException, ConfigInvalidException {
     checkReadEnabled();
 
     try (Timer0.Context ctx = readAllLatency.start();
diff --git a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdsConsistencyCheckerNoteDbImpl.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdsConsistencyCheckerNoteDbImpl.java
new file mode 100644
index 0000000..c44e027
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdsConsistencyCheckerNoteDbImpl.java
@@ -0,0 +1,167 @@
+// 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.account.externalids.storage.notedb;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.HashedPassword;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
+import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.Note;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+@Singleton
+public class ExternalIdsConsistencyCheckerNoteDbImpl implements ExternalIdsConsistencyChecker {
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsers;
+  private final OutgoingEmailValidator validator;
+  private final ExternalIdFactoryNoteDbImpl externalIdFactory;
+
+  @Inject
+  ExternalIdsConsistencyCheckerNoteDbImpl(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsers,
+      OutgoingEmailValidator validator,
+      ExternalIdFactory externalIdFactory) {
+    this.repoManager = repoManager;
+    this.allUsers = allUsers;
+    this.validator = validator;
+    checkState(
+        externalIdFactory instanceof ExternalIdFactoryNoteDbImpl,
+        "ExternalIdsConsistencyCheckerNoteDbImpl must be initiated with"
+            + " ExternalIdFactoryNoteDbImpl.");
+    this.externalIdFactory = (ExternalIdFactoryNoteDbImpl) externalIdFactory;
+  }
+
+  @Override
+  public List<ConsistencyProblemInfo> check(AccountCache accountCache)
+      throws IOException, ConfigInvalidException {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return check(
+          accountCache,
+          ExternalIdNotes.loadReadOnly(allUsers, repo, null, externalIdFactory, false));
+    }
+  }
+
+  @Override
+  public List<ConsistencyProblemInfo> check(AccountCache accountCache, ObjectId rev)
+      throws IOException, ConfigInvalidException {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return check(
+          accountCache,
+          ExternalIdNotes.loadReadOnly(allUsers, repo, rev, externalIdFactory, false));
+    }
+  }
+
+  private List<ConsistencyProblemInfo> check(AccountCache accountCache, ExternalIdNotes extIdNotes)
+      throws IOException {
+    List<ConsistencyProblemInfo> problems = new ArrayList<>();
+
+    ListMultimap<String, ExternalId> emails = MultimapBuilder.hashKeys().arrayListValues().build();
+
+    try (RevWalk rw = new RevWalk(extIdNotes.getRepository())) {
+      NoteMap noteMap = extIdNotes.getNoteMap();
+      for (Note note : noteMap) {
+        byte[] raw = ExternalIdNotes.readNoteData(rw, note.getData());
+        try {
+          ExternalId extId = externalIdFactory.parse(note.getName(), raw, note.getData());
+          problems.addAll(validateExternalId(accountCache, extId));
+
+          if (extId.email() != null) {
+            String email = extId.email();
+            if (emails.get(email).stream()
+                .noneMatch(e -> e.accountId().get() == extId.accountId().get())) {
+              emails.put(email, extId);
+            }
+          }
+        } catch (ConfigInvalidException e) {
+          addError(String.format(e.getMessage()), problems);
+        }
+      }
+    }
+
+    emails.asMap().entrySet().stream()
+        .filter(e -> e.getValue().size() > 1)
+        .forEach(
+            e ->
+                addError(
+                    String.format(
+                        "Email '%s' is not unique, it's used by the following external IDs: %s",
+                        e.getKey(),
+                        e.getValue().stream()
+                            .map(k -> "'" + k.key().get() + "'")
+                            .sorted()
+                            .collect(joining(", "))),
+                    problems));
+
+    return problems;
+  }
+
+  private List<ConsistencyProblemInfo> validateExternalId(
+      AccountCache accountCache, ExternalId extId) {
+    List<ConsistencyProblemInfo> problems = new ArrayList<>();
+
+    if (accountCache.get(extId.accountId()).isEmpty()) {
+      addError(
+          String.format(
+              "External ID '%s' belongs to account that doesn't exist: %s",
+              extId.key().get(), extId.accountId().get()),
+          problems);
+    }
+
+    if (extId.email() != null && !validator.isValid(extId.email())) {
+      addError(
+          String.format(
+              "External ID '%s' has an invalid email: %s", extId.key().get(), extId.email()),
+          problems);
+    }
+
+    if (extId.password() != null && extId.isScheme(SCHEME_USERNAME)) {
+      try {
+        HashedPassword.decode(extId.password());
+      } catch (HashedPassword.DecoderException e) {
+        addError(
+            String.format(
+                "External ID '%s' has an invalid password: %s", extId.key().get(), e.getMessage()),
+            problems);
+      }
+    }
+
+    return problems;
+  }
+
+  private static void addError(String error, List<ConsistencyProblemInfo> problems) {
+    problems.add(new ConsistencyProblemInfo(ConsistencyProblemInfo.Status.ERROR, error));
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdsNoteDbImpl.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdsNoteDbImpl.java
new file mode 100644
index 0000000..7a2945c
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdsNoteDbImpl.java
@@ -0,0 +1,145 @@
+// 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.account.externalids.storage.notedb;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+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.ExternalIdCache;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Class to access external IDs.
+ *
+ * <p>The external IDs are either read from NoteDb or retrieved from the cache.
+ */
+@Singleton
+public class ExternalIdsNoteDbImpl implements ExternalIds {
+  private final ExternalIdReader externalIdReader;
+  @Nullable private final ExternalIdCacheImpl externalIdCache;
+  private final AuthConfig authConfig;
+  private final ExternalIdKeyFactory externalIdKeyFactory;
+
+  @Inject
+  ExternalIdsNoteDbImpl(
+      ExternalIdReader externalIdReader,
+      ExternalIdCache externalIdCache,
+      ExternalIdKeyFactory externalIdKeyFactory,
+      AuthConfig authConfig) {
+    this.externalIdReader = externalIdReader;
+    if (externalIdCache instanceof ExternalIdCacheImpl) {
+      this.externalIdCache = (ExternalIdCacheImpl) externalIdCache;
+    } else if (externalIdCache instanceof DisabledExternalIdCache) {
+      // Supported case for testing only. Non of the disabled cache methods should be called, so
+      // it's safe to not assign the var.
+      this.externalIdCache = null;
+    } else {
+      throw new IllegalStateException(
+          "The cache provided in ExternalIdsNoteDbImpl should be either ExternalIdCacheImpl or"
+              + " DisabledExternalIdCache");
+    }
+    this.externalIdKeyFactory = externalIdKeyFactory;
+    this.authConfig = authConfig;
+  }
+
+  @Override
+  public ImmutableSet<ExternalId> all() throws IOException, ConfigInvalidException {
+    return externalIdReader.all();
+  }
+
+  /** Returns all external IDs from the specified revision of the refs/meta/external-ids branch. */
+  public ImmutableSet<ExternalId> all(ObjectId rev) throws IOException, ConfigInvalidException {
+    return externalIdReader.all(rev);
+  }
+
+  @Override
+  public Optional<ExternalId> get(ExternalId.Key key) throws IOException {
+    Optional<ExternalId> externalId = Optional.empty();
+    if (authConfig.isUserNameCaseInsensitiveMigrationMode()) {
+      externalId =
+          externalIdCache.byKey(externalIdKeyFactory.create(key.scheme(), key.id(), false));
+    }
+    if (!externalId.isPresent()) {
+      externalId = externalIdCache.byKey(key);
+    }
+    return externalId;
+  }
+
+  @Override
+  public ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException {
+    return externalIdCache.byAccount(accountId);
+  }
+
+  @Override
+  public ImmutableSet<ExternalId> byAccount(Account.Id accountId, String scheme)
+      throws IOException {
+    return byAccount(accountId).stream()
+        .filter(e -> e.key().isScheme(scheme))
+        .collect(toImmutableSet());
+  }
+
+  /** Returns the external IDs of the specified account. */
+  public ImmutableSet<ExternalId> byAccount(Account.Id accountId, ObjectId rev) throws IOException {
+    return externalIdCache.byAccount(accountId, rev);
+  }
+
+  @Override
+  public ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException {
+    return externalIdCache.allByAccount();
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * <p>The external IDs are retrieved from the external ID cache. Each access to the external ID *
+   * cache requires reading the SHA1 of the refs/meta/external-ids branch. If external IDs for *
+   * multiple emails are needed it is more efficient to use {@link #byEmails(String...)} as this *
+   * method reads the SHA1 of the refs/meta/external-ids branch only once (and not once per email).
+   *
+   * @see #byEmails(String...)
+   */
+  @Override
+  public ImmutableSet<ExternalId> byEmail(String email) throws IOException {
+    return externalIdCache.byEmail(email);
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * <p>The external IDs are retrieved from the external ID cache. Each access to the external ID
+   * cache requires reading the SHA1 of the refs/meta/external-ids branch. If external IDs for
+   * multiple emails are needed it is more efficient to use this method instead of {@link
+   * #byEmail(String)} as this method reads the SHA1 of the refs/meta/external-ids branch only once
+   * (and not once per email).
+   *
+   * @see #byEmail(String)
+   */
+  @Override
+  public ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException {
+    return externalIdCache.byEmails(emails);
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigrator.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/OnlineExternalIdCaseSensivityMigrator.java
similarity index 92%
rename from java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigrator.java
rename to java/com/google/gerrit/server/account/externalids/storage/notedb/OnlineExternalIdCaseSensivityMigrator.java
index 72e7e90..ab56c94 100644
--- a/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigrator.java
+++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/OnlineExternalIdCaseSensivityMigrator.java
@@ -12,9 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.account.externalids;
+package com.google.gerrit.server.account.externalids.storage.notedb;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.OnlineExternalIdCaseSensivityMigratiorExecutor;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.index.ReindexerAlreadyRunningException;
@@ -23,7 +26,7 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.nio.file.Path;
-import java.util.Collection;
+import java.util.Set;
 import java.util.concurrent.Executor;
 import java.util.concurrent.ExecutorService;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -78,7 +81,7 @@
     executor.execute(
         () -> {
           try {
-            Collection<ExternalId> todo = externalIds.all();
+            Set<ExternalId> todo = externalIds.all();
             try {
               monitor.beginTask("Converting external ID note names", todo.size());
               migratorFactory
diff --git a/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java b/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java
index 7878ee2..dc2fd3c 100644
--- a/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java
+++ b/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdReader;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdReader;
 import java.io.IOException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
diff --git a/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbReadStorageModule.java b/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbReadStorageModule.java
new file mode 100644
index 0000000..9253133
--- /dev/null
+++ b/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbReadStorageModule.java
@@ -0,0 +1,29 @@
+// 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.account.storage.notedb;
+
+import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNoteDbReadStorageModule;
+import com.google.inject.AbstractModule;
+import com.google.inject.Singleton;
+
+public class AccountNoteDbReadStorageModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    install(new ExternalIdNoteDbReadStorageModule());
+
+    bind(Accounts.class).to(AccountsNoteDbImpl.class).in(Singleton.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbWriteStorageModule.java b/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbWriteStorageModule.java
new file mode 100644
index 0000000..24eabb1
--- /dev/null
+++ b/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbWriteStorageModule.java
@@ -0,0 +1,33 @@
+// 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.account.storage.notedb;
+
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNoteDbWriteStorageModule;
+import com.google.inject.AbstractModule;
+
+/** Module that binds {@link AccountsUpdate} */
+public class AccountNoteDbWriteStorageModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    install(new ExternalIdNoteDbWriteStorageModule());
+    bind(AccountsUpdate.AccountsUpdateLoader.class)
+        .annotatedWith(AccountsUpdate.AccountsUpdateLoader.WithReindex.class)
+        .to(AccountsUpdateNoteDbImpl.Factory.class);
+    bind(AccountsUpdate.AccountsUpdateLoader.class)
+        .annotatedWith(AccountsUpdate.AccountsUpdateLoader.NoReindex.class)
+        .to(AccountsUpdateNoteDbImpl.FactoryNoReindex.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/account/storage/notedb/AccountsNoteDbImpl.java b/java/com/google/gerrit/server/account/storage/notedb/AccountsNoteDbImpl.java
new file mode 100644
index 0000000..1da396e
--- /dev/null
+++ b/java/com/google/gerrit/server/account/storage/notedb/AccountsNoteDbImpl.java
@@ -0,0 +1,223 @@
+// 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.account.storage.notedb;
+
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+
+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.Account;
+import com.google.gerrit.entities.NotifyConfig;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.server.account.AccountConfig;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.account.ProjectWatches;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNotes;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdsNoteDbImpl;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.CachedPreferences;
+import com.google.gerrit.server.config.VersionedDefaultPreferences;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Stream;
+import javax.inject.Singleton;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+
+@Singleton
+public class AccountsNoteDbImpl implements Accounts {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final GitRepositoryManager repoManager;
+
+  private final AllUsersName allUsersName;
+
+  private final ExternalIdsNoteDbImpl externalIds;
+  private final Timer0 readSingleLatency;
+
+  @Inject
+  AccountsNoteDbImpl(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      ExternalIdsNoteDbImpl externalIds,
+      MetricMaker metricMaker) {
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.externalIds = externalIds;
+    this.readSingleLatency =
+        metricMaker.newTimer(
+            "notedb/read_single_account_config_latency",
+            new Description("Latency for reading a single account config.")
+                .setCumulative()
+                .setUnit(Description.Units.MILLISECONDS));
+  }
+
+  @Override
+  public Optional<AccountState> get(Account.Id accountId)
+      throws IOException, ConfigInvalidException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return read(repo, accountId);
+    }
+  }
+
+  @Override
+  public List<AccountState> get(Collection<Account.Id> accountIds)
+      throws IOException, ConfigInvalidException {
+    List<AccountState> accounts = new ArrayList<>(accountIds.size());
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      for (Account.Id accountId : accountIds) {
+        read(repo, accountId).ifPresent(accounts::add);
+      }
+    }
+    return accounts;
+  }
+
+  @Override
+  public List<AccountState> all() throws IOException {
+    Set<Account.Id> accountIds = allIds();
+    List<AccountState> accounts = new ArrayList<>(accountIds.size());
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      for (Account.Id accountId : accountIds) {
+        try {
+          read(repo, accountId).ifPresent(accounts::add);
+        } catch (Exception e) {
+          logger.atSevere().withCause(e).log("Ignoring invalid account %s", accountId);
+        }
+      }
+    }
+    return accounts;
+  }
+
+  @Override
+  public Set<Account.Id> allIds() throws IOException {
+    return readUserRefs().collect(toSet());
+  }
+
+  @Override
+  public List<Account.Id> firstNIds(int n) throws IOException {
+    return readUserRefs().sorted(comparing(Account.Id::get)).limit(n).collect(toList());
+  }
+
+  @Override
+  public boolean hasAnyAccount() throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return AccountsNoteDbRepoReader.hasAnyAccount(repo);
+    }
+  }
+
+  /**
+   * Creates an AccountState from the given account config.
+   *
+   * @param externalIds class to access external IDs
+   * @param accountConfig the account config, must already be loaded
+   * @param defaultPreferences the default preferences for this Gerrit installation
+   * @return the account state, {@link Optional#empty()} if the account doesn't exist
+   * @throws IOException if accessing the external IDs fails
+   */
+  static Optional<AccountState> getFromAccountConfig(
+      ExternalIdsNoteDbImpl externalIds,
+      AccountConfig accountConfig,
+      CachedPreferences defaultPreferences)
+      throws IOException {
+    return getFromAccountConfig(externalIds, accountConfig, null, defaultPreferences);
+  }
+
+  /**
+   * Creates an AccountState from the given account config.
+   *
+   * <p>If external ID notes are provided the revision of the external IDs branch from which the
+   * external IDs for the account should be loaded is taken from the external ID notes. If external
+   * ID notes are not given the revision of the external IDs branch is taken from the account
+   * config. Updating external IDs is done via {@link ExternalIdNotes} and if external IDs were
+   * updated the revision of the external IDs branch in account config is outdated. Hence after
+   * updating external IDs the external ID notes must be provided.
+   *
+   * @param externalIds class to access external IDs
+   * @param accountConfig the account config, must already be loaded
+   * @param extIdNotes external ID notes, must already be loaded, may be {@code null}
+   * @param defaultPreferences the default preferences for this Gerrit installation
+   * @return the account state, {@link Optional#empty()} if the account doesn't exist
+   * @throws IOException if accessing the external IDs fails
+   */
+  static Optional<AccountState> getFromAccountConfig(
+      ExternalIdsNoteDbImpl externalIds,
+      AccountConfig accountConfig,
+      @Nullable ExternalIdNotes extIdNotes,
+      CachedPreferences defaultPreferences)
+      throws IOException {
+    if (!accountConfig.getLoadedAccount().isPresent()) {
+      return Optional.empty();
+    }
+    Account account = accountConfig.getLoadedAccount().get();
+
+    Optional<ObjectId> extIdsRev =
+        extIdNotes != null
+            ? Optional.ofNullable(extIdNotes.getRevision())
+            : accountConfig.getExternalIdsRev();
+    ImmutableSet<ExternalId> extIds =
+        extIdsRev.isPresent()
+            ? externalIds.byAccount(account.id(), extIdsRev.get())
+            : ImmutableSet.of();
+
+    // Don't leak references to AccountConfig into the AccountState, since it holds a reference to
+    // an open Repository instance.
+    ImmutableMap<ProjectWatches.ProjectWatchKey, ImmutableSet<NotifyConfig.NotifyType>>
+        projectWatches = accountConfig.getProjectWatches();
+
+    return Optional.of(
+        AccountState.withState(
+            account,
+            extIds,
+            ExternalId.getUserName(extIds),
+            projectWatches,
+            Optional.of(defaultPreferences),
+            Optional.of(accountConfig.asCachedPreferences())));
+  }
+
+  private Stream<Account.Id> readUserRefs() throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return AccountsNoteDbRepoReader.readAllIds(repo);
+    }
+  }
+
+  private Optional<AccountState> read(Repository allUsersRepository, Account.Id accountId)
+      throws IOException, ConfigInvalidException {
+    AccountConfig cfg;
+    CachedPreferences defaultPreferences;
+    try (Timer0.Context ignored = readSingleLatency.start()) {
+      cfg = new AccountConfig(accountId, allUsersName, allUsersRepository).load();
+      defaultPreferences =
+          CachedPreferences.fromLegacyConfig(
+              VersionedDefaultPreferences.get(allUsersRepository, allUsersName));
+    }
+
+    return getFromAccountConfig(externalIds, cfg, defaultPreferences);
+  }
+}
diff --git a/java/com/google/gerrit/server/account/storage/notedb/AccountsNoteDbRepoReader.java b/java/com/google/gerrit/server/account/storage/notedb/AccountsNoteDbRepoReader.java
new file mode 100644
index 0000000..1271d1f
--- /dev/null
+++ b/java/com/google/gerrit/server/account/storage/notedb/AccountsNoteDbRepoReader.java
@@ -0,0 +1,36 @@
+// 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.account.storage.notedb;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.RefNames;
+import java.io.IOException;
+import java.util.Objects;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.Repository;
+
+public class AccountsNoteDbRepoReader {
+  public static boolean hasAnyAccount(Repository repo) throws IOException {
+    return readAllIds(repo).findAny().isPresent();
+  }
+
+  public static Stream<Account.Id> readAllIds(Repository repo) throws IOException {
+    return repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_USERS).stream()
+        .map(r -> Account.Id.fromRef(r.getName()))
+        .filter(Objects::nonNull);
+  }
+
+  private AccountsNoteDbRepoReader() {}
+}
diff --git a/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java b/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
new file mode 100644
index 0000000..265d036
--- /dev/null
+++ b/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
@@ -0,0 +1,588 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.storage.notedb;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.ACCOUNTS_UPDATE;
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.git.LockFailureException;
+import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountConfig;
+import com.google.gerrit.server.account.AccountDelta;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.ProjectWatches;
+import com.google.gerrit.server.account.StoredPreferences;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNotes;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdsNoteDbImpl;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.CachedPreferences;
+import com.google.gerrit.server.config.VersionedDefaultPreferences;
+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.change.ReindexAfterRefUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryableAction.Action;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Consumer;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/**
+ * Creates and updates accounts which are stored in All-Users NoteDB repository.
+ *
+ * <p>Batch updates of multiple different accounts can be performed atomically, see {@link
+ * #updateBatch(List)}. Batch creation is not supported.
+ *
+ * <p>For any account update the caller must provide a commit message, the account ID and an {@link
+ * com.google.gerrit.server.account.AccountsUpdate.ConfigureDeltaFromState}. The account updater
+ * reads the current {@link AccountState} and prepares updates to the account by calling setters on
+ * the provided {@link com.google.gerrit.server.account.AccountDelta.Builder}. If the current
+ * account state is of no interest the caller may also provide a {@link Consumer} for {@link
+ * com.google.gerrit.server.account.AccountDelta.Builder} instead of the account updater.
+ *
+ * <p>The provided commit message is used for the update of the user branch. Using a precise and
+ * unique commit message allows to identify the code from which an update was made when looking at a
+ * commit in the user branch, and thus help debugging.
+ *
+ * <p>The account updates are written to NoteDb. In NoteDb accounts are represented as user branches
+ * in the {@code All-Users} repository. Optionally a user branch can contain a 'account.config' file
+ * that stores account properties, such as full name, display name, preferred email, status and the
+ * active flag. The timestamp of the first commit on a user branch denotes the registration date.
+ * The initial commit on the user branch may be empty (since having an 'account.config' is
+ * optional). See {@link AccountConfig} for details of the 'account.config' file format. In addition
+ * the user branch can contain a 'preferences.config' config file to store preferences (see {@link
+ * StoredPreferences}) and a 'watch.config' config file to store project watches (see {@link
+ * ProjectWatches}). External IDs are stored separately in the {@code refs/meta/external-ids} notes
+ * branch (see {@link ExternalIdNotes}).
+ *
+ * <p>On updating an account the account is evicted from the account cache and reindexed. The
+ * eviction from the account cache and the reindexing is done by the {@link ReindexAfterRefUpdate}
+ * class which receives the event about updating the user branch that is triggered by this class.
+ *
+ * <p>If external IDs are updated, the ExternalIdCache is automatically updated by {@link
+ * ExternalIdNotes}. In addition {@link ExternalIdNotes} takes care about evicting and reindexing
+ * corresponding accounts. This is needed because external ID updates don't touch the user branches.
+ * Hence in this case the accounts are not evicted and reindexed via {@link ReindexAfterRefUpdate}.
+ *
+ * <p>Reindexing and flushing accounts from the account cache can be disabled by
+ *
+ * <ul>
+ *   <li>using {@link
+ *       com.google.gerrit.server.account.storage.notedb.AccountsUpdateNoteDbImpl.FactoryNoReindex}
+ *       and
+ *   <li>binding {@link GitReferenceUpdated#DISABLED}
+ * </ul>
+ *
+ * <p>If there are concurrent account updates which updating the user branch in NoteDb may fail with
+ * {@link LockFailureException}. In this case the account update is automatically retried and the
+ * account updater is invoked once more with the updated account state. This means the whole
+ * read-modify-write sequence is atomic. Retrying is limited by a timeout. If the timeout is
+ * exceeded the account update can still fail with {@link LockFailureException}.
+ */
+public class AccountsUpdateNoteDbImpl extends AccountsUpdate {
+  private static class AbstractFactory implements AccountsUpdateLoader {
+    private final GitRepositoryManager repoManager;
+    private final GitReferenceUpdated gitRefUpdated;
+    private final AllUsersName allUsersName;
+    private final ExternalIdsNoteDbImpl externalIds;
+    private final ExternalIdNotes.ExternalIdNotesLoader extIdNotesFactory;
+    private final Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
+    private final RetryHelper retryHelper;
+    private final Provider<PersonIdent> serverIdentProvider;
+
+    private AbstractFactory(
+        GitRepositoryManager repoManager,
+        GitReferenceUpdated gitRefUpdated,
+        AllUsersName allUsersName,
+        ExternalIdsNoteDbImpl externalIds,
+        Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
+        RetryHelper retryHelper,
+        @GerritPersonIdent Provider<PersonIdent> serverIdentProvider,
+        ExternalIdNotes.ExternalIdNotesLoader extIdNotesFactory) {
+      this.repoManager = repoManager;
+      this.gitRefUpdated = gitRefUpdated;
+      this.allUsersName = allUsersName;
+      this.externalIds = externalIds;
+      this.metaDataUpdateInternalFactory = metaDataUpdateInternalFactory;
+      this.retryHelper = retryHelper;
+      this.serverIdentProvider = serverIdentProvider;
+      this.extIdNotesFactory = extIdNotesFactory;
+    }
+
+    @Override
+    public AccountsUpdate create(IdentifiedUser currentUser) {
+      PersonIdent serverIdent = serverIdentProvider.get();
+      return new AccountsUpdateNoteDbImpl(
+          repoManager,
+          gitRefUpdated,
+          Optional.of(currentUser),
+          allUsersName,
+          externalIds,
+          extIdNotesFactory,
+          metaDataUpdateInternalFactory,
+          retryHelper,
+          serverIdent,
+          AccountsUpdateNoteDbImpl::doNothing,
+          AccountsUpdateNoteDbImpl::doNothing);
+    }
+
+    @Override
+    public AccountsUpdate createWithServerIdent() {
+      PersonIdent serverIdent = serverIdentProvider.get();
+      return new AccountsUpdateNoteDbImpl(
+          repoManager,
+          gitRefUpdated,
+          Optional.empty(),
+          allUsersName,
+          externalIds,
+          extIdNotesFactory,
+          metaDataUpdateInternalFactory,
+          retryHelper,
+          serverIdent,
+          AccountsUpdateNoteDbImpl::doNothing,
+          AccountsUpdateNoteDbImpl::doNothing);
+    }
+  }
+
+  @Singleton
+  public static class Factory extends AbstractFactory {
+    @Inject
+    Factory(
+        GitRepositoryManager repoManager,
+        GitReferenceUpdated gitRefUpdated,
+        AllUsersName allUsersName,
+        ExternalIdsNoteDbImpl externalIds,
+        Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
+        RetryHelper retryHelper,
+        @GerritPersonIdent Provider<PersonIdent> serverIdentProvider,
+        ExternalIdNotes.Factory extIdNotesFactory) {
+      super(
+          repoManager,
+          gitRefUpdated,
+          allUsersName,
+          externalIds,
+          metaDataUpdateInternalFactory,
+          retryHelper,
+          serverIdentProvider,
+          extIdNotesFactory);
+    }
+  }
+
+  @Singleton
+  public static class FactoryNoReindex extends AbstractFactory {
+    @Inject
+    FactoryNoReindex(
+        GitRepositoryManager repoManager,
+        GitReferenceUpdated gitRefUpdated,
+        AllUsersName allUsersName,
+        ExternalIdsNoteDbImpl externalIds,
+        Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
+        RetryHelper retryHelper,
+        @GerritPersonIdent Provider<PersonIdent> serverIdentProvider,
+        ExternalIdNotes.FactoryNoReindex extIdNotesFactory) {
+      super(
+          repoManager,
+          gitRefUpdated,
+          allUsersName,
+          externalIds,
+          metaDataUpdateInternalFactory,
+          retryHelper,
+          serverIdentProvider,
+          extIdNotesFactory);
+    }
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final GitReferenceUpdated gitRefUpdated;
+  private final AllUsersName allUsersName;
+  private final ExternalIdsNoteDbImpl externalIds;
+
+  private final ExternalIdNotes.ExternalIdNotesLoader extIdNotesFactory;
+  private final Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
+  private final RetryHelper retryHelper;
+
+  /** Invoked after reading the account config. */
+  private final Runnable afterReadRevision;
+
+  /** Invoked after updating the account but before committing the changes. */
+  private final Runnable beforeCommit;
+
+  /** Single instance that accumulates updates from the batch. */
+  @Nullable private ExternalIdNotes externalIdNotes;
+
+  @VisibleForTesting
+  public AccountsUpdateNoteDbImpl(
+      GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
+      Optional<IdentifiedUser> currentUser,
+      AllUsersName allUsersName,
+      ExternalIdsNoteDbImpl externalIds,
+      ExternalIdNotes.ExternalIdNotesLoader extIdNotesFactory,
+      Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory,
+      RetryHelper retryHelper,
+      PersonIdent committerIdent,
+      Runnable afterReadRevision,
+      Runnable beforeCommit) {
+    super(committerIdent, currentUser);
+    this.repoManager = requireNonNull(repoManager, "repoManager");
+    this.gitRefUpdated = requireNonNull(gitRefUpdated, "gitRefUpdated");
+    this.allUsersName = requireNonNull(allUsersName, "allUsersName");
+    this.externalIds = requireNonNull(externalIds, "externalIds");
+    this.extIdNotesFactory = extIdNotesFactory;
+    this.metaDataUpdateInternalFactory =
+        requireNonNull(metaDataUpdateInternalFactory, "metaDataUpdateInternalFactory");
+    this.retryHelper = requireNonNull(retryHelper, "retryHelper");
+    this.afterReadRevision = requireNonNull(afterReadRevision, "afterReadRevision");
+    this.beforeCommit = requireNonNull(beforeCommit, "beforeCommit");
+  }
+
+  @Override
+  public AccountState insert(String message, Account.Id accountId, ConfigureDeltaFromState init)
+      throws IOException, ConfigInvalidException {
+    return execute(
+            ImmutableList.of(
+                repo -> {
+                  AccountConfig accountConfig = read(repo, accountId);
+                  Account account = accountConfig.getNewAccount(committerIdent.getWhenAsInstant());
+                  AccountState accountState = AccountState.forAccount(account);
+                  AccountDelta.Builder deltaBuilder = AccountDelta.builder();
+                  init.configure(accountState, deltaBuilder);
+
+                  AccountDelta accountDelta = deltaBuilder.build();
+                  accountConfig.setAccountDelta(accountDelta);
+                  updateExternalIdNotes(
+                      repo, accountConfig.getExternalIdsRev(), accountId, accountDelta);
+                  CachedPreferences defaultPreferences =
+                      CachedPreferences.fromLegacyConfig(
+                          VersionedDefaultPreferences.get(repo, allUsersName));
+
+                  return new UpdatedAccount(message, accountConfig, defaultPreferences, true);
+                }))
+        .get(0)
+        .get();
+  }
+
+  @Override
+  public void delete(String message, Account.Id accountId)
+      throws IOException, ConfigInvalidException {
+    ImmutableSet<ExternalId> accountExternalIds = externalIds.byAccount(accountId);
+    Consumer<AccountDelta.Builder> delta =
+        deltaBuilder -> deltaBuilder.deleteAccount(accountExternalIds);
+    update(message, accountId, delta);
+  }
+
+  private ExecutableUpdate createExecutableUpdate(UpdateArguments updateArguments) {
+    return repo -> {
+      AccountConfig accountConfig = read(repo, updateArguments.accountId);
+      CachedPreferences defaultPreferences =
+          CachedPreferences.fromLegacyConfig(VersionedDefaultPreferences.get(repo, allUsersName));
+      Optional<AccountState> accountState =
+          AccountsNoteDbImpl.getFromAccountConfig(externalIds, accountConfig, defaultPreferences);
+      if (!accountState.isPresent()) {
+        return null;
+      }
+
+      AccountDelta.Builder deltaBuilder = AccountDelta.builder();
+      updateArguments.configureDeltaFromState.configure(accountState.get(), deltaBuilder);
+
+      AccountDelta delta = deltaBuilder.build();
+      updateExternalIdNotes(
+          repo, accountConfig.getExternalIdsRev(), updateArguments.accountId, delta);
+
+      if (delta.getShouldDeleteAccount().orElse(false)) {
+        return new DeletedAccount(updateArguments.message, accountConfig.getRefName());
+      }
+
+      accountConfig.setAccountDelta(delta);
+      CachedPreferences cachedDefaultPreferences =
+          CachedPreferences.fromLegacyConfig(VersionedDefaultPreferences.get(repo, allUsersName));
+      return new UpdatedAccount(
+          updateArguments.message, accountConfig, cachedDefaultPreferences, false);
+    };
+  }
+
+  private void updateExternalIdNotes(
+      Repository allUsersRepo, Optional<ObjectId> rev, Account.Id accountId, AccountDelta update)
+      throws IOException, ConfigInvalidException {
+
+    if (update.hasExternalIdUpdates()) {
+
+      // Only load the externalIds if they are going to be updated
+      // This makes e.g. preferences updates faster.
+      ExternalIdNotes.checkSameAccount(
+          Iterables.concat(
+              update.getCreatedExternalIds(),
+              update.getUpdatedExternalIds(),
+              update.getDeletedExternalIds()),
+          accountId);
+      if (externalIdNotes == null) {
+        externalIdNotes = extIdNotesFactory.load(allUsersRepo, rev.orElse(ObjectId.zeroId()));
+      }
+      externalIdNotes.replace(update.getDeletedExternalIds(), update.getCreatedExternalIds());
+      externalIdNotes.upsert(update.getUpdatedExternalIds());
+    }
+  }
+
+  private AccountConfig read(Repository allUsersRepo, Account.Id accountId)
+      throws IOException, ConfigInvalidException {
+    AccountConfig accountConfig = new AccountConfig(accountId, allUsersName, allUsersRepo).load();
+    afterReadRevision.run();
+    return accountConfig;
+  }
+
+  @Override
+  protected ImmutableList<Optional<AccountState>> executeUpdates(List<UpdateArguments> updates)
+      throws ConfigInvalidException, IOException {
+    return execute(updates.stream().map(this::createExecutableUpdate).collect(toImmutableList()));
+  }
+
+  private ImmutableList<Optional<AccountState>> execute(List<ExecutableUpdate> executableUpdates)
+      throws IOException, ConfigInvalidException {
+    try (RefUpdateContext ctx = RefUpdateContext.open(ACCOUNTS_UPDATE)) {
+      List<Optional<AccountState>> accountState = new ArrayList<>();
+      List<UpdatedAccount> updatedAccounts = new ArrayList<>();
+      executeWithRetry(
+          () -> {
+
+            // Reset state for retry.
+            externalIdNotes = null;
+            accountState.clear();
+            updatedAccounts.clear();
+            try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+              for (ExecutableUpdate executableUpdate : executableUpdates) {
+                updatedAccounts.add(executableUpdate.execute(allUsersRepo));
+              }
+              commit(
+                  allUsersRepo,
+                  updatedAccounts.stream().filter(Objects::nonNull).collect(toList()));
+              for (UpdatedAccount ua : updatedAccounts) {
+                accountState.add(
+                    ua == null || ua.deleted ? Optional.empty() : ua.getAccountState());
+              }
+            }
+            return null;
+          });
+
+      return ImmutableList.copyOf(accountState);
+    }
+  }
+
+  private void executeWithRetry(Action<Void> action) throws IOException, ConfigInvalidException {
+    try {
+      retryHelper.accountUpdate("updateAccount", action).call();
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      Throwables.throwIfInstanceOf(e, IOException.class);
+      Throwables.throwIfInstanceOf(e, ConfigInvalidException.class);
+      throw new StorageException(e);
+    }
+  }
+
+  private void commit(Repository allUsersRepo, List<UpdatedAccount> updatedAccounts)
+      throws IOException {
+    if (updatedAccounts.isEmpty()) {
+      return;
+    }
+
+    beforeCommit.run();
+
+    BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
+    Set<Account.Id> accountsToSkipForReindex = new HashSet<>();
+    //  External ids may be not updated if:
+    //  * externalIdNotes is not loaded  (there were no externalId updates in the delta)
+    //  * new revCommit is identical to the previous externalId tip
+    boolean externalIdsUpdated = false;
+    if (externalIdNotes != null) {
+      String externalIdUpdateMessage =
+          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);
+      externalIdsUpdated = !Objects.equals(revCommit.getId(), oldExternalIdsRevision);
+    }
+    for (UpdatedAccount updatedAccount : updatedAccounts) {
+      if (updatedAccount.deleted) {
+        RefUpdate ru = RefUpdateUtil.deleteChecked(allUsersRepo, updatedAccount.refName);
+        gitRefUpdated.fire(allUsersName, ru, ReceiveCommand.Type.DELETE, null);
+        accountsToSkipForReindex.add(Account.Id.fromRef(updatedAccount.refName));
+        continue;
+      }
+      // 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,
+          allowEmptyCommit);
+    }
+
+    RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
+
+    if (externalIdsUpdated) {
+      accountsToSkipForReindex.addAll(getUpdatedAccountIds(batchRefUpdate));
+      extIdNotesFactory.updateExternalIdCacheAndMaybeReindexAccounts(
+          externalIdNotes, accountsToSkipForReindex);
+    }
+
+    gitRefUpdated.fire(
+        allUsersName, batchRefUpdate, currentUser.map(IdentifiedUser::state).orElse(null));
+  }
+
+  private static Set<Account.Id> getUpdatedAccountIds(BatchRefUpdate batchRefUpdate) {
+    return batchRefUpdate.getCommands().stream()
+        .map(c -> Account.Id.fromRef(c.getRefName()))
+        .filter(Objects::nonNull)
+        .collect(toSet());
+  }
+
+  private void commitAccountConfig(
+      String message,
+      Repository allUsersRepo,
+      BatchRefUpdate batchRefUpdate,
+      AccountConfig accountConfig,
+      boolean allowEmptyCommit)
+      throws IOException {
+    try (MetaDataUpdate md = createMetaDataUpdate(message, allUsersRepo, batchRefUpdate)) {
+      md.setAllowEmpty(allowEmptyCommit);
+      accountConfig.commit(md);
+    }
+  }
+
+  private RevCommit commitExternalIdUpdates(
+      String message, Repository allUsersRepo, BatchRefUpdate batchRefUpdate) throws IOException {
+    try (MetaDataUpdate md = createMetaDataUpdate(message, allUsersRepo, batchRefUpdate)) {
+      return externalIdNotes.commit(md);
+    }
+  }
+
+  private MetaDataUpdate createMetaDataUpdate(
+      String message, Repository allUsersRepo, BatchRefUpdate batchRefUpdate) {
+    MetaDataUpdate metaDataUpdate =
+        metaDataUpdateInternalFactory.get().create(allUsersName, allUsersRepo, batchRefUpdate);
+    if (!message.endsWith("\n")) {
+      message = message + "\n";
+    }
+
+    metaDataUpdate.getCommitBuilder().setMessage(message);
+    metaDataUpdate.getCommitBuilder().setCommitter(committerIdent);
+    metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
+    return metaDataUpdate;
+  }
+
+  private static void doNothing() {}
+
+  @FunctionalInterface
+  private interface ExecutableUpdate {
+    UpdatedAccount execute(Repository allUsersRepo) throws IOException, ConfigInvalidException;
+  }
+
+  private class UpdatedAccount {
+    final String message;
+    final AccountConfig accountConfig;
+    final CachedPreferences defaultPreferences;
+    final String refName;
+    final boolean created;
+    final boolean deleted;
+
+    UpdatedAccount(
+        String message,
+        AccountConfig accountConfig,
+        CachedPreferences defaultPreferences,
+        boolean created) {
+      this(
+          message,
+          requireNonNull(accountConfig),
+          defaultPreferences,
+          accountConfig.getRefName(),
+          created,
+          false);
+    }
+
+    protected UpdatedAccount(
+        String message,
+        AccountConfig accountConfig,
+        CachedPreferences defaultPreferences,
+        String refName,
+        boolean created,
+        boolean deleted) {
+      checkState(!Strings.isNullOrEmpty(message), "message for account update must be set");
+      this.message = requireNonNull(message);
+      this.accountConfig = accountConfig;
+      this.defaultPreferences = defaultPreferences;
+      this.refName = refName;
+      this.created = created;
+      this.deleted = deleted;
+    }
+
+    Optional<AccountState> getAccountState() throws IOException {
+      return AccountsNoteDbImpl.getFromAccountConfig(
+          externalIds, accountConfig, externalIdNotes, defaultPreferences);
+    }
+  }
+
+  private class DeletedAccount extends UpdatedAccount {
+    DeletedAccount(String message, String refName) {
+      super(message, null, null, refName, false, true);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 828f868..400521b 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -54,6 +54,7 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.restapi.account.AddSshKey;
 import com.google.gerrit.server.restapi.account.CreateEmail;
+import com.google.gerrit.server.restapi.account.DeleteAccount;
 import com.google.gerrit.server.restapi.account.DeleteActive;
 import com.google.gerrit.server.restapi.account.DeleteDraftComments;
 import com.google.gerrit.server.restapi.account.DeleteEmail;
@@ -135,6 +136,7 @@
   private final EmailApiImpl.Factory emailApi;
   private final PutName putName;
   private final PutHttpPassword putHttpPassword;
+  private final DeleteAccount deleteAccount;
 
   @Inject
   AccountApiImpl(
@@ -176,6 +178,7 @@
       EmailApiImpl.Factory emailApi,
       PutName putName,
       PutHttpPassword putPassword,
+      DeleteAccount deleteAccount,
       @Assisted AccountResource account) {
     this.account = account;
     this.accountLoaderFactory = ailf;
@@ -216,6 +219,7 @@
     this.emailApi = emailApi;
     this.putName = putName;
     this.putHttpPassword = putPassword;
+    this.deleteAccount = deleteAccount;
   }
 
   @Override
@@ -604,4 +608,13 @@
       throw asRestApiException("Cannot generate HTTP password", e);
     }
   }
+
+  @Override
+  public void delete() throws RestApiException {
+    try {
+      deleteAccount.apply(account, null);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete account " + account.getUser().getNameEmail(), e);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 4fba660..62650d2 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
@@ -27,6 +28,7 @@
 import com.google.gerrit.extensions.api.changes.ChangeEditApi;
 import com.google.gerrit.extensions.api.changes.ChangeMessageApi;
 import com.google.gerrit.extensions.api.changes.Changes;
+import com.google.gerrit.extensions.api.changes.CustomKeyedValuesInput;
 import com.google.gerrit.extensions.api.changes.FixInput;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
@@ -80,6 +82,7 @@
 import com.google.gerrit.server.restapi.change.DeleteChange;
 import com.google.gerrit.server.restapi.change.DeletePrivate;
 import com.google.gerrit.server.restapi.change.GetChange;
+import com.google.gerrit.server.restapi.change.GetCustomKeyedValues;
 import com.google.gerrit.server.restapi.change.GetHashtags;
 import com.google.gerrit.server.restapi.change.GetMetaDiff;
 import com.google.gerrit.server.restapi.change.GetPureRevert;
@@ -90,6 +93,7 @@
 import com.google.gerrit.server.restapi.change.ListChangeRobotComments;
 import com.google.gerrit.server.restapi.change.ListReviewers;
 import com.google.gerrit.server.restapi.change.Move;
+import com.google.gerrit.server.restapi.change.PostCustomKeyedValues;
 import com.google.gerrit.server.restapi.change.PostHashtags;
 import com.google.gerrit.server.restapi.change.PostPrivate;
 import com.google.gerrit.server.restapi.change.PostReviewers;
@@ -151,6 +155,8 @@
   private final Provider<GetMetaDiff> getMetaDiffProvider;
   private final PostHashtags postHashtags;
   private final GetHashtags getHashtags;
+  private final PostCustomKeyedValues postCustomKeyedValues;
+  private final GetCustomKeyedValues getCustomKeyedValues;
   private final AttentionSet attentionSet;
   private final AttentionSetApiImpl.Factory attentionSetApi;
   private final AddToAttentionSet addToAttentionSet;
@@ -201,6 +207,8 @@
       Provider<GetMetaDiff> getMetaDiffProvider,
       PostHashtags postHashtags,
       GetHashtags getHashtags,
+      PostCustomKeyedValues postCustomKeyedValues,
+      GetCustomKeyedValues getCustomKeyedValues,
       AttentionSet attentionSet,
       AttentionSetApiImpl.Factory attentionSetApi,
       AddToAttentionSet addToAttentionSet,
@@ -249,6 +257,8 @@
     this.getMetaDiffProvider = getMetaDiffProvider;
     this.postHashtags = postHashtags;
     this.getHashtags = getHashtags;
+    this.postCustomKeyedValues = postCustomKeyedValues;
+    this.getCustomKeyedValues = getCustomKeyedValues;
     this.attentionSet = attentionSet;
     this.attentionSetApi = attentionSetApi;
     this.addToAttentionSet = addToAttentionSet;
@@ -568,6 +578,25 @@
   }
 
   @Override
+  public void setCustomKeyedValues(CustomKeyedValuesInput input) throws RestApiException {
+    try {
+      @SuppressWarnings("unused")
+      var unused = postCustomKeyedValues.apply(change, input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot post custom keyed values", e);
+    }
+  }
+
+  @Override
+  public ImmutableMap<String, String> getCustomKeyedValues() throws RestApiException {
+    try {
+      return getCustomKeyedValues.apply(change).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get custom keyed values", e);
+    }
+  }
+
+  @Override
   public AccountInfo addToAttentionSet(AttentionSetInput input) throws RestApiException {
     try {
       return addToAttentionSet.apply(change, input).value();
diff --git a/java/com/google/gerrit/server/api/changes/ChangesImpl.java b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
index 6b107f1..1666820 100644
--- a/java/com/google/gerrit/server/api/changes/ChangesImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
@@ -146,6 +146,7 @@
       qc.setLimit(q.getLimit());
       qc.setStart(q.getStart());
       qc.setNoLimit(q.getNoLimit());
+      qc.setAllowIncompleteResults(q.getAllowIncompleteResults());
       for (ListChangesOption option : q.getOptions()) {
         qc.addOption(option);
       }
diff --git a/java/com/google/gerrit/server/api/projects/BranchApiImpl.java b/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
index 78f5c5f..1fad91d 100644
--- a/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
@@ -17,11 +17,13 @@
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ChangeApi.SuggestedReviewersRequest;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ReflogEntryInfo;
 import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -36,6 +38,7 @@
 import com.google.gerrit.server.restapi.project.GetBranch;
 import com.google.gerrit.server.restapi.project.GetContent;
 import com.google.gerrit.server.restapi.project.GetReflog;
+import com.google.gerrit.server.restapi.project.SuggestBranchReviewers;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -56,6 +59,8 @@
   private final String ref;
   private final ProjectResource project;
 
+  private final SuggestBranchReviewers suggestReviewers;
+
   @Inject
   BranchApiImpl(
       BranchesCollection branches,
@@ -65,6 +70,7 @@
       GetBranch getBranch,
       GetContent getContent,
       GetReflog getReflog,
+      SuggestBranchReviewers suggestReviewers,
       @Assisted ProjectResource project,
       @Assisted String ref) {
     this.branches = branches;
@@ -75,6 +81,7 @@
     this.getContent = getContent;
     this.getReflog = getReflog;
     this.project = project;
+    this.suggestReviewers = suggestReviewers;
     this.ref = ref;
   }
 
@@ -107,6 +114,29 @@
   }
 
   @Override
+  public SuggestedReviewersRequest suggestReviewers() throws RestApiException {
+    return new SuggestedReviewersRequest() {
+      @Override
+      public List<SuggestedReviewerInfo> get() throws RestApiException {
+        return BranchApiImpl.this.suggestReviewers(this);
+      }
+    };
+  }
+
+  private List<SuggestedReviewerInfo> suggestReviewers(SuggestedReviewersRequest r)
+      throws RestApiException {
+    try {
+      suggestReviewers.setQuery(r.getQuery());
+      suggestReviewers.setLimit(r.getLimit());
+      suggestReviewers.setExcludeGroups(r.getExcludeGroups());
+      suggestReviewers.setReviewerState(r.getReviewerState());
+      return suggestReviewers.apply(resource()).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve suggested reviewers", e);
+    }
+  }
+
+  @Override
   public BinaryResult file(String path) throws RestApiException {
     try {
       FileResource resource = filesCollection.parse(resource(), IdString.fromDecoded(path));
diff --git a/java/com/google/gerrit/server/api/projects/ProjectsImpl.java b/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
index f311b35..78cf811 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.project.ListProjects;
 import com.google.gerrit.server.restapi.project.ListProjects.FilterType;
 import com.google.gerrit.server.restapi.project.ProjectsCollection;
@@ -94,8 +93,7 @@
     };
   }
 
-  private SortedMap<String, ProjectInfo> list(ListRequest request)
-      throws RestApiException, PermissionBackendException {
+  private SortedMap<String, ProjectInfo> list(ListRequest request) throws Exception {
     ListProjects lp = listProvider.get();
     lp.setShowDescription(request.getDescription());
     lp.setLimit(request.getLimit());
diff --git a/java/com/google/gerrit/server/approval/ApprovalsUtil.java b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
index e4a22832d8..cd9c9ba 100644
--- a/java/com/google/gerrit/server/approval/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
@@ -288,7 +288,7 @@
     return addCcs(update, wantCCs, notes.load().getReviewers(), keepExistingReviewers);
   }
 
-  private Collection<Account.Id> addCcs(
+  private Set<Account.Id> addCcs(
       ChangeUpdate update,
       Collection<Account.Id> wantCCs,
       ReviewerSet existingReviewers,
diff --git a/java/com/google/gerrit/server/audit/BUILD b/java/com/google/gerrit/server/audit/BUILD
index 39cec8b..654996a 100644
--- a/java/com/google/gerrit/server/audit/BUILD
+++ b/java/com/google/gerrit/server/audit/BUILD
@@ -10,68 +10,12 @@
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
-        "//lib:args4j",
-        "//lib:autolink",
-        "//lib:automaton",
-        "//lib:blame-cache",
-        "//lib:flexmark",
-        "//lib:flexmark-ext-abbreviation",
-        "//lib:flexmark-ext-anchorlink",
-        "//lib:flexmark-ext-autolink",
-        "//lib:flexmark-ext-definition",
-        "//lib:flexmark-ext-emoji",
-        "//lib:flexmark-ext-escaped-character",
-        "//lib:flexmark-ext-footnotes",
-        "//lib:flexmark-ext-gfm-issues",
-        "//lib:flexmark-ext-gfm-strikethrough",
-        "//lib:flexmark-ext-gfm-tables",
-        "//lib:flexmark-ext-gfm-tasklist",
-        "//lib:flexmark-ext-gfm-users",
-        "//lib:flexmark-ext-ins",
-        "//lib:flexmark-ext-jekyll-front-matter",
-        "//lib:flexmark-ext-superscript",
-        "//lib:flexmark-ext-tables",
-        "//lib:flexmark-ext-toc",
-        "//lib:flexmark-ext-typographic",
-        "//lib:flexmark-ext-wikilink",
-        "//lib:flexmark-ext-yaml-front-matter",
-        "//lib:flexmark-formatter",
-        "//lib:flexmark-html-parser",
-        "//lib:flexmark-profile-pegdown",
-        "//lib:flexmark-util",
-        "//lib:gson",
         "//lib:guava",
-        "//lib:guava-retrying",
         "//lib:jgit",
-        "//lib:jgit-archive",
-        "//lib:juniversalchardet",
-        "//lib:mime-util",
-        "//lib:protobuf",
         "//lib:servlet-api",
-        "//lib:soy",
-        "//lib:tukaani-xz",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
-        "//lib/bouncycastle:bcpkix-neverlink",
-        "//lib/bouncycastle:bcprov-neverlink",
-        "//lib/commons:compress",
-        "//lib/commons:dbcp",
-        "//lib/commons:lang3",
-        "//lib/commons:net",
-        "//lib/commons:validator",
         "//lib/flogger:api",
         "//lib/guice",
-        "//lib/guice:guice-assistedinject",
-        "//lib/guice:guice-servlet",
-        "//lib/jsoup",
-        "//lib/lucene:lucene-analyzers-common",
-        "//lib/lucene:lucene-queryparser",
-        "//lib/mime4j:core",
-        "//lib/mime4j:dom",
-        "//lib/ow2:ow2-asm",
-        "//lib/ow2:ow2-asm-tree",
-        "//lib/ow2:ow2-asm-util",
-        "//lib/prolog:runtime",
-        "//proto:cache_java_proto",
     ],
 )
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index fdd55ac..fd0bf31 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -231,7 +231,8 @@
         maxSize,
         expireAfterWrite,
         refreshAfterWrite,
-        buildBloomFilter);
+        buildBloomFilter,
+        isOfflineReindex);
   }
 
   private boolean has(String name, String var) {
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index 27a09ed..bda101e 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -367,6 +367,7 @@
     private volatile BloomFilter<K> bloomFilter;
     private int estimatedSize;
     private boolean buildBloomFilter;
+    private boolean isOfflineReindex;
 
     SqlStore(
         String jdbcUrl,
@@ -377,7 +378,8 @@
         long maxSize,
         @Nullable Duration expireAfterWrite,
         @Nullable Duration refreshAfterWrite,
-        boolean buildBloomFilter) {
+        boolean buildBloomFilter,
+        boolean isOfflineReindex) {
       this.url = jdbcUrl;
       this.keyType = createKeyType(keyType, keySerializer);
       this.valueSerializer = valueSerializer;
@@ -386,6 +388,7 @@
       this.expireAfterWrite = expireAfterWrite;
       this.refreshAfterWrite = refreshAfterWrite;
       this.buildBloomFilter = buildBloomFilter;
+      this.isOfflineReindex = isOfflineReindex;
 
       int cores = Runtime.getRuntime().availableProcessors();
       int keep = Math.min(cores, 16);
@@ -509,7 +512,9 @@
           ValueHolder<V> h = new ValueHolder<>(val, created.toInstant());
           h.clean = true;
           hitCount.incrementAndGet();
-          touch(c, key);
+          if (!isOfflineReindex) {
+            touch(c, key);
+          }
           return h;
         } finally {
           c.get.clearParameters();
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/ProjectSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/ProjectSerializer.java
index aa1f4ce..3f89002c 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/ProjectSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/ProjectSerializer.java
@@ -20,6 +20,7 @@
 
 import com.google.common.base.Converter;
 import com.google.common.base.Enums;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.InheritableBoolean;
@@ -27,7 +28,6 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.server.cache.proto.Cache;
 import java.util.Arrays;
-import java.util.Set;
 
 /** Helper to (de)serialize values for caches. */
 public class ProjectSerializer {
@@ -48,7 +48,7 @@
             .setLocalDefaultDashboard(emptyToNull(proto.getLocalDefaultDashboard()))
             .setConfigRefState(emptyToNull(proto.getConfigRefState()));
 
-    Set<String> configs =
+    ImmutableSet<String> configs =
         Arrays.stream(BooleanProjectConfig.values())
             .map(BooleanProjectConfig::name)
             .collect(toImmutableSet());
diff --git a/java/com/google/gerrit/server/change/AbandonOp.java b/java/com/google/gerrit/server/change/AbandonOp.java
index 10e1f92..f68867be 100644
--- a/java/com/google/gerrit/server/change/AbandonOp.java
+++ b/java/com/google/gerrit/server/change/AbandonOp.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.server.mail.EmailFactories.CHANGE_ABANDONED;
+
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
@@ -25,9 +27,10 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.extensions.events.ChangeAbandoned;
-import com.google.gerrit.server.mail.send.AbandonedSender;
+import com.google.gerrit.server.mail.EmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ReplyToChangeSender;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -38,7 +41,7 @@
 public class AbandonOp implements BatchUpdateOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final AbandonedSender.Factory abandonedSenderFactory;
+  private final EmailFactories emailFactories;
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
   private final ChangeAbandoned changeAbandoned;
@@ -58,14 +61,14 @@
 
   @Inject
   AbandonOp(
-      AbandonedSender.Factory abandonedSenderFactory,
+      EmailFactories emailFactories,
       ChangeMessagesUtil cmUtil,
       PatchSetUtil psUtil,
       ChangeAbandoned changeAbandoned,
       MessageIdGenerator messageIdGenerator,
       @Assisted @Nullable AccountState accountState,
       @Assisted @Nullable String msgTxt) {
-    this.abandonedSenderFactory = abandonedSenderFactory;
+    this.emailFactories = emailFactories;
     this.cmUtil = cmUtil;
     this.psUtil = psUtil;
     this.changeAbandoned = changeAbandoned;
@@ -111,16 +114,17 @@
   public void postUpdate(PostUpdateContext ctx) {
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     try {
-      ReplyToChangeSender emailSender =
-          abandonedSenderFactory.create(ctx.getProject(), change.getId());
+      ChangeEmail changeEmail =
+          emailFactories.createChangeEmail(
+              ctx.getProject(), change.getId(), emailFactories.createAbandonedChangeEmail());
+      changeEmail.setChangeMessage(mailMessage, ctx.getWhen());
+      OutgoingEmail email = emailFactories.createOutgoingEmail(CHANGE_ABANDONED, changeEmail);
       if (accountState != null) {
-        emailSender.setFrom(accountState.account().id());
+        email.setFrom(accountState.account().id());
       }
-      emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-      emailSender.setNotify(notify);
-      emailSender.setMessageId(
-          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
-      emailSender.send();
+      email.setNotify(notify);
+      email.setMessageId(messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
+      email.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
     }
diff --git a/java/com/google/gerrit/server/change/AbandonUtil.java b/java/com/google/gerrit/server/change/AbandonUtil.java
index 59b32c2..6734434 100644
--- a/java/com/google/gerrit/server/change/AbandonUtil.java
+++ b/java/com/google/gerrit/server/change/AbandonUtil.java
@@ -104,7 +104,7 @@
 
   private Collection<ChangeData> getValidChanges(Collection<ChangeData> changes, String query)
       throws QueryParseException {
-    Collection<ChangeData> validChanges = new ArrayList<>();
+    List<ChangeData> validChanges = new ArrayList<>();
     for (ChangeData cd : changes) {
       String newQuery = query + " change:" + cd.getId();
       List<ChangeData> changesToAbandon =
diff --git a/java/com/google/gerrit/server/change/AccountPatchReviewStore.java b/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
index 1b9008d..dc34095 100644
--- a/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
 import java.util.Collection;
 import java.util.Optional;
 
@@ -91,6 +92,16 @@
   void clearReviewed(Change.Id changeId);
 
   /**
+   * Clears the reviewed flags for the given user in all the relevant changes/patch-set/files.
+   *
+   * @param accountId account ID of the user
+   */
+  default void clearReviewedBy(Account.Id accountId) {
+    throw new NotImplementedException(
+        "clearReviewedBy() is not implemented for this AccountPatchReviewStore.");
+  }
+
+  /**
    * Find the latest patch set, that is smaller or equals to the given patch set, where at least,
    * one file has been reviewed by the given user.
    *
diff --git a/java/com/google/gerrit/server/change/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java
index e5a9534..52230ba 100644
--- a/java/com/google/gerrit/server/change/ActionJson.java
+++ b/java/com/google/gerrit/server/change/ActionJson.java
@@ -123,6 +123,10 @@
         changeInfo.removedFromAttentionSet == null
             ? null
             : ImmutableMap.copyOf(changeInfo.removedFromAttentionSet);
+    copy.customKeyedValues =
+        changeInfo.customKeyedValues == null
+            ? null
+            : ImmutableMap.copyOf(changeInfo.customKeyedValues);
     copy.hashtags = changeInfo.hashtags;
     copy.changeId = changeInfo.changeId;
     copy.submitType = changeInfo.submitType;
@@ -141,12 +145,12 @@
     copy.revertOf = changeInfo.revertOf;
     copy.submissionId = changeInfo.submissionId;
     copy.starred = changeInfo.starred;
-    copy.stars = changeInfo.stars;
     copy.submitted = changeInfo.submitted;
     copy.submitter = changeInfo.submitter;
     copy.unresolvedCommentCount = changeInfo.unresolvedCommentCount;
     copy.workInProgress = changeInfo.workInProgress;
     copy.id = changeInfo.id;
+    copy.tripletId = changeInfo.tripletId;
     copy.cherryPickOfChange = changeInfo.cherryPickOfChange;
     copy.cherryPickOfPatchSet = changeInfo.cherryPickOfPatchSet;
     return copy;
@@ -163,6 +167,7 @@
     copy.isCurrent = revisionInfo.isCurrent;
     copy._number = revisionInfo._number;
     copy.ref = revisionInfo.ref;
+    copy.branch = revisionInfo.branch;
     copy.created = revisionInfo.created;
     copy.uploader = revisionInfo.uploader;
     copy.realUploader = revisionInfo.realUploader;
diff --git a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
index ec90bec..f0a70bb 100644
--- a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator.AttentionSetChange.USER_ADDED;
 import static java.util.Objects.requireNonNull;
 
 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.notedb.ChangeUpdate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -37,7 +37,6 @@
   }
 
   private final ChangeData.Factory changeDataFactory;
-  private final AddToAttentionSetSender.Factory addToAttentionSetSender;
   private final AttentionSetEmail.Factory attentionSetEmailFactory;
 
   private final Account.Id attentionUserId;
@@ -56,15 +55,12 @@
   @Inject
   AddToAttentionSetOp(
       ChangeData.Factory changeDataFactory,
-      AddToAttentionSetSender.Factory addToAttentionSetSender,
       AttentionSetEmail.Factory attentionSetEmailFactory,
       @Assisted Account.Id attentionUserId,
       @Assisted String reason,
       @Assisted boolean notify) {
     this.changeDataFactory = changeDataFactory;
-    this.addToAttentionSetSender = addToAttentionSetSender;
     this.attentionSetEmailFactory = attentionSetEmailFactory;
-
     this.attentionUserId = requireNonNull(attentionUserId, "user");
     this.reason = requireNonNull(reason, "reason");
     this.notify = notify;
@@ -97,13 +93,6 @@
     if (!notify) {
       return;
     }
-    attentionSetEmailFactory
-        .create(
-            addToAttentionSetSender.create(ctx.getProject(), change.getId()),
-            ctx,
-            change,
-            reason,
-            attentionUserId)
-        .sendAsync();
+    attentionSetEmailFactory.create(USER_ADDED, ctx, change, reason, attentionUserId).sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/change/AttentionSetOwnerAdder.java b/java/com/google/gerrit/server/change/AttentionSetOwnerAdder.java
new file mode 100644
index 0000000..2e2769b
--- /dev/null
+++ b/java/com/google/gerrit/server/change/AttentionSetOwnerAdder.java
@@ -0,0 +1,87 @@
+// 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.change;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.AttentionSetConfig;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
+import com.google.inject.Inject;
+
+/** Runnable to enable scheduling change cleanups to run periodically */
+public class AttentionSetOwnerAdder implements Runnable {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public static class AttentionSetOwnerAdderModule extends LifecycleModule {
+    @Override
+    protected void configure() {
+      listener().to(Lifecycle.class);
+    }
+  }
+
+  static class Lifecycle implements LifecycleListener {
+    private final WorkQueue queue;
+    private final AttentionSetOwnerAdder runner;
+    private final AttentionSetConfig cfg;
+
+    @Inject
+    Lifecycle(WorkQueue queue, AttentionSetOwnerAdder runner, AttentionSetConfig cfg) {
+      this.queue = queue;
+      this.runner = runner;
+      this.cfg = cfg;
+    }
+
+    @Override
+    public void start() {
+      cfg.getSchedule().ifPresent(s -> queue.scheduleAtFixedRate(runner, s));
+    }
+
+    @Override
+    public void stop() {
+      // handled by WorkQueue.stop() already
+    }
+  }
+
+  private final OneOffRequestContext oneOffRequestContext;
+  private final BatchUpdate.Factory updateFactory;
+  private final ReaddOwnerUtil readdOwnerUtil;
+
+  @Inject
+  AttentionSetOwnerAdder(
+      OneOffRequestContext oneOffRequestContext,
+      BatchUpdate.Factory updateFactory,
+      ReaddOwnerUtil readdOwnerUtil) {
+    this.oneOffRequestContext = oneOffRequestContext;
+    this.updateFactory = updateFactory;
+    this.readdOwnerUtil = readdOwnerUtil;
+  }
+
+  @Override
+  public void run() {
+    logger.atInfo().log("Running attention-set owner adder.");
+    try (ManualRequestContext ctx = oneOffRequestContext.open()) {
+      readdOwnerUtil.readdOwnerForInactiveOpenChanges(updateFactory);
+    }
+  }
+
+  @Override
+  public String toString() {
+    return "attention-set adder";
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 8773bb7..f32b2eb 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -19,6 +19,8 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.Change.INITIAL_PATCH_SET_ID;
 import static com.google.gerrit.server.change.ReviewerModifier.newReviewerInputFromCommitIdentity;
+import static com.google.gerrit.server.mail.EmailFactories.REVIEW_REQUESTED;
+import static com.google.gerrit.server.notedb.ChangeUpdate.MAX_CUSTOM_KEYED_VALUES;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Objects.requireNonNull;
@@ -26,6 +28,7 @@
 import com.google.common.base.MoreObjects;
 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.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
@@ -43,7 +46,6 @@
 import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -56,15 +58,18 @@
 import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification;
 import com.google.gerrit.server.change.ReviewerModifier.ReviewerModificationList;
 import com.google.gerrit.server.config.SendEmailExecutor;
-import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.mail.send.CreateChangeSender;
+import com.google.gerrit.server.git.validators.TopicValidator;
+import com.google.gerrit.server.mail.EmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
+import com.google.gerrit.server.mail.send.StartReviewChangeEmailDecorator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.AutoMerger;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
@@ -110,15 +115,16 @@
   private final PatchSetUtil psUtil;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
-  private final CreateChangeSender.Factory createChangeSenderFactory;
+  private final EmailFactories emailFactories;
   private final ExecutorService sendEmailExecutor;
   private final CommitValidators.Factory commitValidatorsFactory;
+  private final TopicValidator topicValidator;
   private final RevisionCreated revisionCreated;
   private final CommentAdded commentAdded;
   private final ReviewerModifier reviewerModifier;
   private final MessageIdGenerator messageIdGenerator;
-  private final DynamicItem<UrlFormatter> urlFormatter;
   private final AutoMerger autoMerger;
+  private final ChangeUtil changeUtil;
 
   private final Change.Id changeId;
   private final PatchSet.Id psId;
@@ -135,6 +141,7 @@
   private boolean workInProgress;
   private List<String> groups = Collections.emptyList();
   private ImmutableListMultimap<String, String> validationOptions = ImmutableListMultimap.of();
+  private ImmutableMap<String, String> customKeyedValues = ImmutableMap.of();
   private boolean validate = true;
   private Map<String, Short> approvals;
   private RequestScopePropagator requestScopePropagator;
@@ -162,15 +169,16 @@
       PatchSetUtil psUtil,
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
-      CreateChangeSender.Factory createChangeSenderFactory,
+      EmailFactories emailFactories,
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       CommitValidators.Factory commitValidatorsFactory,
+      TopicValidator topicValidator,
       CommentAdded commentAdded,
       RevisionCreated revisionCreated,
       ReviewerModifier reviewerModifier,
       MessageIdGenerator messageIdGenerator,
-      DynamicItem<UrlFormatter> urlFormatter,
       AutoMerger autoMerger,
+      ChangeUtil changeUtil,
       @Assisted Change.Id changeId,
       @Assisted ObjectId commitId,
       @Assisted String refName) {
@@ -180,15 +188,16 @@
     this.psUtil = psUtil;
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
-    this.createChangeSenderFactory = createChangeSenderFactory;
+    this.emailFactories = emailFactories;
     this.sendEmailExecutor = sendEmailExecutor;
     this.commitValidatorsFactory = commitValidatorsFactory;
+    this.topicValidator = topicValidator;
     this.revisionCreated = revisionCreated;
     this.commentAdded = commentAdded;
     this.reviewerModifier = reviewerModifier;
     this.messageIdGenerator = messageIdGenerator;
-    this.urlFormatter = urlFormatter;
     this.autoMerger = autoMerger;
+    this.changeUtil = changeUtil;
 
     this.changeId = changeId;
     this.psId = PatchSet.id(changeId, INITIAL_PATCH_SET_ID);
@@ -223,7 +232,7 @@
   private Change.Key getChangeKey(RevWalk rw) throws IOException {
     RevCommit commit = rw.parseCommit(commitId);
     rw.parseBody(commit);
-    List<String> idList = ChangeUtil.getChangeIdsFromFooter(commit, urlFormatter.get());
+    List<String> idList = changeUtil.getChangeIdsFromFooter(commit);
     if (!idList.isEmpty()) {
       return Change.key(idList.get(idList.size() - 1).trim());
     }
@@ -343,6 +352,13 @@
   }
 
   @CanIgnoreReturnValue
+  public ChangeInserter setCustomKeyedValues(ImmutableMap<String, String> customKeyedValues) {
+    requireNonNull(customKeyedValues, "customKeyedValues may not be null");
+    this.customKeyedValues = customKeyedValues;
+    return this;
+  }
+
+  @CanIgnoreReturnValue
   public ChangeInserter setValidationOptions(
       ImmutableListMultimap<String, String> validationOptions) {
     requireNonNull(validationOptions, "validationOptions may not be null");
@@ -457,10 +473,22 @@
     update.setSubjectForCommit("Create change");
     update.setBranch(change.getDest().branch());
     try {
-      update.setTopic(change.getTopic());
+      update.setTopic(change.getTopic(), topicValidator);
     } catch (ValidationException ex) {
       throw new BadRequestException(ex.getMessage());
     }
+    if (customKeyedValues != null) {
+      try {
+        if (customKeyedValues.entrySet().size() > MAX_CUSTOM_KEYED_VALUES) {
+          throw new ValidationException("Too many custom keyed values");
+        }
+        for (Map.Entry<String, String> entry : customKeyedValues.entrySet()) {
+          update.addCustomKeyedValue(entry.getKey(), entry.getValue());
+        }
+      } catch (ValidationException ex) {
+        throw new BadRequestException(ex.getMessage());
+      }
+    }
     update.setPsDescription(patchSetDescription);
     update.setPrivate(isPrivate);
     update.setWorkInProgress(workInProgress);
@@ -531,24 +559,30 @@
             @Override
             public void run() {
               try {
-                CreateChangeSender emailSender =
-                    createChangeSenderFactory.create(change.getProject(), change.getId());
-                emailSender.setFrom(change.getOwner());
-                emailSender.setPatchSet(patchSet, patchSetInfo);
-                emailSender.setNotify(notify);
-                emailSender.addReviewers(
+                StartReviewChangeEmailDecorator startReviewEmail =
+                    emailFactories.createStartReviewChangeEmail();
+                startReviewEmail.markAsCreateChange();
+                startReviewEmail.addReviewers(
                     reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewers).stream()
                         .map(PatchSetApproval::accountId)
                         .collect(toImmutableSet()));
-                emailSender.addReviewersByEmail(
+                startReviewEmail.addReviewersByEmail(
                     reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewersByEmail));
-                emailSender.addExtraCC(
+                startReviewEmail.addExtraCC(
                     reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCs));
-                emailSender.addExtraCCByEmail(
+                startReviewEmail.addExtraCCByEmail(
                     reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCsByEmail));
-                emailSender.setMessageId(
+                ChangeEmail changeEmail =
+                    emailFactories.createChangeEmail(
+                        change.getProject(), change.getId(), startReviewEmail);
+                changeEmail.setPatchSet(patchSet, patchSetInfo);
+                OutgoingEmail outgoingEmail =
+                    emailFactories.createOutgoingEmail(REVIEW_REQUESTED, changeEmail);
+                outgoingEmail.setFrom(change.getOwner());
+                outgoingEmail.setNotify(notify);
+                outgoingEmail.setMessageId(
                     messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
-                emailSender.send();
+                outgoingEmail.send();
               } catch (Exception e) {
                 logger.atSevere().withCause(e).log(
                     "Cannot send email for new change %s", change.getId());
@@ -659,20 +693,20 @@
     }
     return Streams.concat(
             reviewerInputs.stream(),
-            Streams.stream(
-                newReviewerInputFromCommitIdentity(
-                    change,
-                    patchSetInfo.getCommitId(),
-                    patchSetInfo.getAuthor().getAccount(),
-                    NotifyHandling.NONE,
-                    change.getOwner())),
-            Streams.stream(
-                newReviewerInputFromCommitIdentity(
-                    change,
-                    patchSetInfo.getCommitId(),
-                    patchSetInfo.getCommitter().getAccount(),
-                    NotifyHandling.NONE,
-                    change.getOwner())))
+            newReviewerInputFromCommitIdentity(
+                change,
+                patchSetInfo.getCommitId(),
+                patchSetInfo.getAuthor().getAccount(),
+                NotifyHandling.NONE,
+                change.getOwner())
+                .stream(),
+            newReviewerInputFromCommitIdentity(
+                change,
+                patchSetInfo.getCommitId(),
+                patchSetInfo.getCommitter().getAccount(),
+                NotifyHandling.NONE,
+                change.getOwner())
+                .stream())
         .collect(toImmutableList());
   }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index de8f2c7..1a9e4f8 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -23,6 +23,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_ACTIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.extensions.client.ListChangesOption.CUSTOM_KEYED_VALUES;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_ACCOUNTS;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.LABELS;
@@ -96,7 +97,7 @@
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
-import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesReader;
 import com.google.gerrit.server.account.AccountInfoComparator;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.cancellation.RequestCancelledException;
@@ -231,7 +232,7 @@
   private final AccountLoader.Factory accountLoaderFactory;
   private final ImmutableSet<ListChangesOption> options;
   private final ChangeMessagesUtil cmUtil;
-  private final StarredChangesUtil starredChangesUtil;
+  private final StarredChangesReader starredChangesreader;
   private final Provider<ConsistencyChecker> checkerProvider;
   private final ActionJson actionJson;
   private final ChangeNotes.Factory notesFactory;
@@ -257,7 +258,7 @@
       ChangeData.Factory cdf,
       AccountLoader.Factory ailf,
       ChangeMessagesUtil cmUtil,
-      StarredChangesUtil starredChangesUtil,
+      StarredChangesReader starredChangesreader,
       Provider<ConsistencyChecker> checkerProvider,
       ActionJson actionJson,
       ChangeNotes.Factory notesFactory,
@@ -276,7 +277,7 @@
     this.permissionBackend = permissionBackend;
     this.accountLoaderFactory = ailf;
     this.cmUtil = cmUtil;
-    this.starredChangesUtil = starredChangesUtil;
+    this.starredChangesreader = starredChangesreader;
     this.checkerProvider = checkerProvider;
     this.actionJson = actionJson;
     this.notesFactory = notesFactory;
@@ -372,8 +373,8 @@
     return format(cd, Optional.empty(), true, getPluginInfos(cd));
   }
 
-  private static Collection<LegacySubmitRequirementInfo> requirementsFor(ChangeData cd) {
-    Collection<LegacySubmitRequirementInfo> reqInfos = new ArrayList<>();
+  private static List<LegacySubmitRequirementInfo> requirementsFor(ChangeData cd) {
+    List<LegacySubmitRequirementInfo> reqInfos = new ArrayList<>();
     for (SubmitRecord submitRecord : cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)) {
       if (submitRecord.requirements == null) {
         continue;
@@ -385,7 +386,7 @@
     return reqInfos;
   }
 
-  private Collection<SubmitRecordInfo> submitRecordsFor(ChangeData cd) {
+  private List<SubmitRecordInfo> submitRecordsFor(ChangeData cd) {
     List<SubmitRecordInfo> submitRecordInfos = new ArrayList<>();
     for (SubmitRecord record : cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)) {
       submitRecordInfos.add(submitRecordToInfo(record));
@@ -393,8 +394,8 @@
     return submitRecordInfos;
   }
 
-  private Collection<SubmitRequirementResultInfo> submitRequirementsFor(ChangeData cd) {
-    Collection<SubmitRequirementResultInfo> reqInfos = new ArrayList<>();
+  private List<SubmitRequirementResultInfo> submitRequirementsFor(ChangeData cd) {
+    List<SubmitRequirementResultInfo> reqInfos = new ArrayList<>();
     cd.submitRequirementsIncludingLegacy().entrySet().stream()
         .filter(entry -> !entry.getValue().isHidden())
         .forEach(
@@ -436,9 +437,11 @@
   }
 
   private static void finish(ChangeInfo info) {
-    info.id =
+    info.tripletId =
         Joiner.on('~')
             .join(Url.encode(info.project), Url.encode(info.branch), Url.encode(info.changeId));
+    info.id =
+        Joiner.on('~').join(Url.encode(info.project), Url.encode(String.valueOf(info._number)));
   }
 
   private static boolean containsAnyOf(
@@ -518,6 +521,13 @@
         // (2) Reusing
         boolean isCacheable = cacheQueryResultsByChangeNum && (i != changes.size() - 1);
         ChangeData cd = changes.get(i);
+        if (cd.hasFailedParsingFromIndex()) {
+          Optional<ChangeInfo> faultyChangeInfo = createFaultyChangeInfo(cd);
+          if (faultyChangeInfo.isPresent()) {
+            changeInfos.add(faultyChangeInfo.get());
+          }
+          continue;
+        }
         ChangeInfo info = cache.get(cd.getId());
         if (info != null && isCacheable) {
           changeInfos.add(info);
@@ -636,6 +646,9 @@
                       a -> a.account().get(),
                       a -> AttentionSetUtil.createAttentionSetInfo(a, accountLoader)));
     }
+    if (has(CUSTOM_KEYED_VALUES)) {
+      out.customKeyedValues = cd.customKeyedValues();
+    }
     out.hashtags = cd.hashtags();
     out.changeId = in.getKey().get();
     if (in.isNew()) {
@@ -679,10 +692,8 @@
     }
 
     if (user.isIdentifiedUser()) {
-      Collection<String> stars = cd.stars(user.getAccountId());
-      out.starred = stars.contains(StarredChangesUtil.DEFAULT_LABEL) ? true : null;
-      if (!stars.isEmpty()) {
-        out.stars = stars;
+      if (cd.isStarred(user.getAccountId())) {
+        out.starred = true;
       }
     }
 
@@ -750,14 +761,13 @@
 
     // This block must come after the ChangeInfo is mostly populated, since
     // it will be passed to ActionVisitors as-is.
+
     if (needRevisions) {
       out.revisions = revisionJson.getRevisions(accountLoader, cd, src, limitToPsId, out);
-      if (out.revisions != null) {
-        for (Map.Entry<String, RevisionInfo> entry : out.revisions.entrySet()) {
-          if (entry.getValue().isCurrent) {
-            out.currentRevision = entry.getKey();
-            break;
-          }
+      for (Map.Entry<String, RevisionInfo> entry : out.revisions.entrySet()) {
+        if (entry.getValue().isCurrent) {
+          out.currentRevision = entry.getKey();
+          break;
         }
       }
     }
@@ -795,7 +805,7 @@
     return reviewerMap;
   }
 
-  private Collection<ReviewerUpdateInfo> reviewerUpdates(ChangeData cd) {
+  private List<ReviewerUpdateInfo> reviewerUpdates(ChangeData cd) {
     List<ReviewerStatusUpdate> reviewerUpdates = cd.reviewerUpdates();
     List<ReviewerUpdateInfo> result = new ArrayList<>(reviewerUpdates.size());
     for (ReviewerStatusUpdate c : reviewerUpdates) {
@@ -836,7 +846,7 @@
     return ImmutableList.copyOf(result);
   }
 
-  private Collection<AccountInfo> removableReviewers(ChangeData cd, ChangeInfo out)
+  private List<AccountInfo> removableReviewers(ChangeData cd, ChangeInfo out)
       throws PermissionBackendException {
     // Although this is called removableReviewers, this method also determines
     // which CCs are removable.
@@ -919,14 +929,14 @@
     return result;
   }
 
-  private Collection<AccountInfo> toAccountInfo(Collection<Account.Id> accounts) {
+  private List<AccountInfo> toAccountInfo(Collection<Account.Id> accounts) {
     return accounts.stream()
         .map(accountLoader::get)
         .sorted(AccountInfoComparator.ORDER_NULLS_FIRST)
         .collect(toList());
   }
 
-  private Collection<AccountInfo> toAccountInfoByEmail(Collection<Address> addresses) {
+  private List<AccountInfo> toAccountInfoByEmail(Collection<Address> addresses) {
     return addresses.stream()
         .map(a -> new AccountInfo(a.name(), a.email()))
         .sorted(AccountInfoComparator.ORDER_NULLS_FIRST)
@@ -969,7 +979,7 @@
       List<Change.Id> changeIds =
           changeInfos.stream().map(c -> Change.id(c.virtualIdNumber)).collect(Collectors.toList());
       Set<Change.Id> starredChanges =
-          starredChangesUtil.areStarred(
+          starredChangesreader.areStarred(
               allUsersRepo, changeIds, userProvider.get().asIdentifiedUser().getAccountId());
       if (starredChanges.isEmpty()) {
         return;
@@ -992,4 +1002,25 @@
     }
     return ImmutableListMultimap.of();
   }
+
+  /**
+   * Create an empty {@link ChangeInfo} designating a faulty record if {@link
+   * ChangeData#hasFailedParsingFromIndex()} is true.
+   *
+   * <p>Few fields are populated: project, branch, changeId, _number, subject, owner.
+   */
+  private static Optional<ChangeInfo> createFaultyChangeInfo(ChangeData cd) {
+    ChangeInfo info = new ChangeInfo();
+    Change c = cd.change();
+    if (c == null) {
+      return Optional.empty();
+    }
+    info.project = c.getProject().get();
+    info.branch = c.getDest().shortName();
+    info.changeId = c.getKey().get();
+    info._number = c.getId().get();
+    info.subject = "***ERROR***";
+    info.owner = new AccountInfo(c.getOwner().get());
+    return Optional.of(info);
+  }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index ee48c7f..fe7fd8e 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -32,7 +32,7 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesReader;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.approval.ApprovalsUtil;
@@ -76,7 +76,7 @@
   private final ApprovalsUtil approvalUtil;
   private final PatchSetUtil patchSetUtil;
   private final PermissionBackend permissionBackend;
-  private final StarredChangesUtil starredChangesUtil;
+  private final StarredChangesReader starredChangesReader;
   private final ProjectCache projectCache;
   private final PluginSetContext<ChangeETagComputation> changeETagComputation;
   private final ChangeData changeData;
@@ -88,7 +88,7 @@
       ApprovalsUtil approvalUtil,
       PatchSetUtil patchSetUtil,
       PermissionBackend permissionBackend,
-      StarredChangesUtil starredChangesUtil,
+      StarredChangesReader starredChangesReader,
       ProjectCache projectCache,
       PluginSetContext<ChangeETagComputation> changeETagComputation,
       ChangeData.Factory changeDataFactory,
@@ -98,7 +98,7 @@
     this.approvalUtil = approvalUtil;
     this.patchSetUtil = patchSetUtil;
     this.permissionBackend = permissionBackend;
-    this.starredChangesUtil = starredChangesUtil;
+    this.starredChangesReader = starredChangesReader;
     this.projectCache = projectCache;
     this.changeETagComputation = changeETagComputation;
     this.changeData = changeDataFactory.create(notes);
@@ -111,7 +111,7 @@
       ApprovalsUtil approvalUtil,
       PatchSetUtil patchSetUtil,
       PermissionBackend permissionBackend,
-      StarredChangesUtil starredChangesUtil,
+      StarredChangesReader starredChangesReader,
       ProjectCache projectCache,
       PluginSetContext<ChangeETagComputation> changeETagComputation,
       @Assisted ChangeData changeData,
@@ -120,7 +120,7 @@
     this.approvalUtil = approvalUtil;
     this.patchSetUtil = patchSetUtil;
     this.permissionBackend = permissionBackend;
-    this.starredChangesUtil = starredChangesUtil;
+    this.starredChangesReader = starredChangesReader;
     this.projectCache = projectCache;
     this.changeETagComputation = changeETagComputation;
     this.changeData = changeData;
@@ -241,8 +241,7 @@
                 .build())) {
       Hasher h = Hashing.murmur3_128().newHasher();
       if (user.isIdentifiedUser()) {
-        h.putString(
-            starredChangesUtil.getObjectId(user.getAccountId(), getVirtualId()).name(), UTF_8);
+        h.putBoolean(starredChangesReader.isStarred(user.getAccountId(), getVirtualId()));
       }
       prepareETag(h, user);
       return h.hash().toString();
diff --git a/java/com/google/gerrit/server/change/ConsistencyChecker.java b/java/com/google/gerrit/server/change/ConsistencyChecker.java
index 063903b..b216db3 100644
--- a/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -38,14 +38,12 @@
 import com.google.gerrit.extensions.api.changes.FixInput;
 import com.google.gerrit.extensions.common.ProblemInfo;
 import com.google.gerrit.extensions.common.ProblemInfo.Status;
-import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.Accounts;
-import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.PatchSetState;
@@ -118,7 +116,7 @@
   private final Provider<CurrentUser> user;
   private final Provider<PersonIdent> serverIdent;
   private final RetryHelper retryHelper;
-  private final DynamicItem<UrlFormatter> urlFormatter;
+  private final ChangeUtil changeUtil;
 
   private BatchUpdate.Factory updateFactory;
   private FixInput fix;
@@ -146,7 +144,7 @@
       PatchSetUtil psUtil,
       Provider<CurrentUser> user,
       RetryHelper retryHelper,
-      DynamicItem<UrlFormatter> urlFormatter) {
+      ChangeUtil changeUtil) {
     this.accounts = accounts;
     this.accountPatchReviewStore = accountPatchReviewStore;
     this.notesFactory = notesFactory;
@@ -157,7 +155,7 @@
     this.retryHelper = retryHelper;
     this.serverIdent = serverIdent;
     this.user = user;
-    this.urlFormatter = urlFormatter;
+    this.changeUtil = changeUtil;
     reset();
   }
 
@@ -463,9 +461,7 @@
         case 0:
           // No patch set for this commit; insert one.
           rw.parseBody(commit);
-          String changeId =
-              Iterables.getFirst(
-                  ChangeUtil.getChangeIdsFromFooter(commit, urlFormatter.get()), null);
+          String changeId = Iterables.getFirst(changeUtil.getChangeIdsFromFooter(commit), null);
           // Missing Change-Id footer is ok, but mismatched is not.
           if (changeId != null && !changeId.equals(change().getKey().get())) {
             problem(
diff --git a/java/com/google/gerrit/server/change/CustomKeyedValuesUtil.java b/java/com/google/gerrit/server/change/CustomKeyedValuesUtil.java
new file mode 100644
index 0000000..55b4d74
--- /dev/null
+++ b/java/com/google/gerrit/server/change/CustomKeyedValuesUtil.java
@@ -0,0 +1,81 @@
+// 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.change;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import java.util.Map;
+import java.util.Set;
+
+public class CustomKeyedValuesUtil {
+  public static class InvalidCustomKeyedValueException extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    static InvalidCustomKeyedValueException customKeyedValuesMayNotContainEquals() {
+      return new InvalidCustomKeyedValueException("custom keys may not contain equals sign");
+    }
+
+    static InvalidCustomKeyedValueException customKeyedValuesMayNotContainNewLine() {
+      return new InvalidCustomKeyedValueException("custom values may not contain newline");
+    }
+
+    InvalidCustomKeyedValueException(String message) {
+      super(message);
+    }
+  }
+
+  static ImmutableMap<String, String> extractCustomKeyedValues(Map<String, String> input)
+      throws InvalidCustomKeyedValueException {
+    if (input == null) {
+      return ImmutableMap.of();
+    }
+    ImmutableMap.Builder<String, String> builder = ImmutableMap.builder();
+    for (Map.Entry<String, String> customKeyedValue : input.entrySet()) {
+      if (customKeyedValue.getKey().contains("=")) {
+        throw InvalidCustomKeyedValueException.customKeyedValuesMayNotContainEquals();
+      }
+      if (customKeyedValue.getValue().contains("\n")) {
+        throw InvalidCustomKeyedValueException.customKeyedValuesMayNotContainNewLine();
+      }
+      String key = customKeyedValue.getKey().trim();
+      if (key.isEmpty()) {
+        continue;
+      }
+      builder.put(key, customKeyedValue.getValue());
+    }
+    return builder.build();
+  }
+
+  static ImmutableSet<String> extractCustomKeys(Set<String> input)
+      throws InvalidCustomKeyedValueException {
+    if (input == null) {
+      return ImmutableSet.of();
+    }
+    ImmutableSet.Builder<String> builder = ImmutableSet.builder();
+    for (String customKey : input) {
+      if (customKey.contains("=")) {
+        throw InvalidCustomKeyedValueException.customKeyedValuesMayNotContainEquals();
+      }
+      String key = customKey.trim();
+      if (key.isEmpty()) {
+        continue;
+      }
+      builder.add(key);
+    }
+    return builder.build();
+  }
+
+  private CustomKeyedValuesUtil() {}
+}
diff --git a/java/com/google/gerrit/server/change/DeleteChangeOp.java b/java/com/google/gerrit/server/change/DeleteChangeOp.java
index 4ac27c1..435f2f1 100644
--- a/java/com/google/gerrit/server/change/DeleteChangeOp.java
+++ b/java/com/google/gerrit/server/change/DeleteChangeOp.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesWriter;
 import com.google.gerrit.server.extensions.events.ChangeDeleted;
 import com.google.gerrit.server.plugincontext.PluginItemContext;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -48,7 +48,7 @@
   }
 
   private final PatchSetUtil psUtil;
-  private final StarredChangesUtil starredChangesUtil;
+  private final StarredChangesWriter starredChangesWriter;
   private final PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore;
   private final ChangeData.Factory changeDataFactory;
   private final ChangeDeleted changeDeleted;
@@ -57,13 +57,13 @@
   @Inject
   DeleteChangeOp(
       PatchSetUtil psUtil,
-      StarredChangesUtil starredChangesUtil,
+      StarredChangesWriter starredChangesWriter,
       PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore,
       ChangeData.Factory changeDataFactory,
       ChangeDeleted changeDeleted,
       @Assisted Change.Id id) {
     this.psUtil = psUtil;
-    this.starredChangesUtil = starredChangesUtil;
+    this.starredChangesWriter = starredChangesWriter;
     this.accountPatchReviewStore = accountPatchReviewStore;
     this.changeDataFactory = changeDataFactory;
     this.changeDeleted = changeDeleted;
@@ -128,7 +128,7 @@
     accountPatchReviewStore.run(s -> s.clearReviewed(cd.virtualId()));
 
     // Non-atomic operation on All-Users refs; not much we can do to make it atomic.
-    starredChangesUtil.unstarAllForChangeDeletion(cd.virtualId());
+    starredChangesWriter.unstarAllForChangeDeletion(cd.virtualId());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
index f3fd68e..0fe526c 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -14,13 +14,18 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.server.mail.EmailFactories.REVIEWER_DELETED;
+
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.mail.EmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
+import com.google.gerrit.server.mail.send.DeleteReviewerChangeEmailDecorator;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.PostUpdateContext;
@@ -35,7 +40,7 @@
     DeleteReviewerByEmailOp create(Address reviewer);
   }
 
-  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
+  private final EmailFactories emailFactories;
   private final MessageIdGenerator messageIdGenerator;
   private final ChangeMessagesUtil changeMessagesUtil;
 
@@ -45,11 +50,11 @@
 
   @Inject
   DeleteReviewerByEmailOp(
-      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
+      EmailFactories emailFactories,
       MessageIdGenerator messageIdGenerator,
       ChangeMessagesUtil changeMessagesUtil,
       @Assisted Address reviewer) {
-    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
+    this.emailFactories = emailFactories;
     this.messageIdGenerator = messageIdGenerator;
     this.changeMessagesUtil = changeMessagesUtil;
     this.reviewer = reviewer;
@@ -76,15 +81,19 @@
     if (sendEmail) {
       try {
         NotifyResolver.Result notify = ctx.getNotify(change.getId());
-        DeleteReviewerSender emailSender =
-            deleteReviewerSenderFactory.create(ctx.getProject(), change.getId());
-        emailSender.setFrom(ctx.getAccountId());
-        emailSender.addReviewersByEmail(Collections.singleton(reviewer));
-        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-        emailSender.setNotify(notify);
-        emailSender.setMessageId(
+        DeleteReviewerChangeEmailDecorator deleteReviewerEmail =
+            emailFactories.createDeleteReviewerChangeEmail();
+        deleteReviewerEmail.addReviewersByEmail(Collections.singleton(reviewer));
+        ChangeEmail changeEmail =
+            emailFactories.createChangeEmail(ctx.getProject(), change.getId(), deleteReviewerEmail);
+        changeEmail.setChangeMessage(mailMessage, ctx.getWhen());
+        OutgoingEmail outgoingEmail =
+            emailFactories.createOutgoingEmail(REVIEWER_DELETED, changeEmail);
+        outgoingEmail.setFrom(ctx.getAccountId());
+        outgoingEmail.setNotify(notify);
+        outgoingEmail.setMessageId(
             messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
-        emailSender.send();
+        outgoingEmail.send();
       } catch (Exception err) {
         logger.atSevere().withCause(err).log("Cannot email update for change %s", change.getId());
       }
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 785f9e1..90cb9a9 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.server.mail.EmailFactories.REVIEWER_DELETED;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
 import com.google.common.collect.Iterables;
@@ -36,8 +37,11 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.extensions.events.ReviewerDeleted;
-import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.mail.EmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
+import com.google.gerrit.server.mail.send.DeleteReviewerChangeEmailDecorator;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -68,7 +72,7 @@
   private final ChangeMessagesUtil cmUtil;
   private final ReviewerDeleted reviewerDeleted;
   private final Provider<IdentifiedUser> user;
-  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
+  private final EmailFactories emailFactories;
   private final RemoveReviewerControl removeReviewerControl;
   private final ProjectCache projectCache;
   private final MessageIdGenerator messageIdGenerator;
@@ -89,7 +93,7 @@
       ChangeMessagesUtil cmUtil,
       ReviewerDeleted reviewerDeleted,
       Provider<IdentifiedUser> user,
-      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
+      EmailFactories emailFactories,
       RemoveReviewerControl removeReviewerControl,
       ProjectCache projectCache,
       MessageIdGenerator messageIdGenerator,
@@ -101,7 +105,7 @@
     this.cmUtil = cmUtil;
     this.reviewerDeleted = reviewerDeleted;
     this.user = user;
-    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
+    this.emailFactories = emailFactories;
     this.removeReviewerControl = removeReviewerControl;
     this.projectCache = projectCache;
     this.messageIdGenerator = messageIdGenerator;
@@ -250,14 +254,17 @@
       // The user knows they removed themselves, don't bother emailing them.
       return;
     }
-    DeleteReviewerSender emailSender =
-        deleteReviewerSenderFactory.create(projectName, change.getId());
-    emailSender.setFrom(userId);
-    emailSender.addReviewers(Collections.singleton(reviewer.id()));
-    emailSender.setChangeMessage(mailMessage, timestamp.toInstant());
-    emailSender.setNotify(notify);
-    emailSender.setMessageId(
+    DeleteReviewerChangeEmailDecorator deleteReviewerEmail =
+        emailFactories.createDeleteReviewerChangeEmail();
+    deleteReviewerEmail.addReviewers(Collections.singleton(reviewer.id()));
+    ChangeEmail changeEmail =
+        emailFactories.createChangeEmail(projectName, change.getId(), deleteReviewerEmail);
+    changeEmail.setChangeMessage(mailMessage, timestamp.toInstant());
+    OutgoingEmail outgoingEmail = emailFactories.createOutgoingEmail(REVIEWER_DELETED, changeEmail);
+    outgoingEmail.setFrom(userId);
+    outgoingEmail.setNotify(notify);
+    outgoingEmail.setMessageId(
         messageIdGenerator.fromChangeUpdate(repoView, change.currentPatchSetId()));
-    emailSender.send();
+    outgoingEmail.send();
   }
 }
diff --git a/java/com/google/gerrit/server/change/DraftCommentResource.java b/java/com/google/gerrit/server/change/DraftCommentResource.java
index 2e40f2c..14df66b 100644
--- a/java/com/google/gerrit/server/change/DraftCommentResource.java
+++ b/java/com/google/gerrit/server/change/DraftCommentResource.java
@@ -35,6 +35,10 @@
     this.comment = c;
   }
 
+  public RevisionResource getRevisionResource() {
+    return rev;
+  }
+
   public CurrentUser getUser() {
     return rev.getUser();
   }
diff --git a/java/com/google/gerrit/server/change/EmailNewPatchSet.java b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
index f67ce4a..b295469 100644
--- a/java/com/google/gerrit/server/change/EmailNewPatchSet.java
+++ b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.server.mail.EmailFactories.NEW_PATCHSET_ADDED;
+
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
@@ -28,9 +30,12 @@
 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.EmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 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.mail.send.OutgoingEmail;
+import com.google.gerrit.server.mail.send.ReplacePatchSetChangeEmailDecorator;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.util.RequestContext;
@@ -71,7 +76,7 @@
   EmailNewPatchSet(
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       ThreadLocalRequestContext threadLocalRequestContext,
-      ReplacePatchSetSender.Factory replacePatchSetFactory,
+      EmailFactories emailFactories,
       PatchSetInfoFactory patchSetInfoFactory,
       MessageIdGenerator messageIdGenerator,
       @Assisted PostUpdateContext postUpdateContext,
@@ -107,7 +112,7 @@
     this.asyncSender =
         new AsyncSender(
             postUpdateContext.getIdentifiedUser(),
-            replacePatchSetFactory,
+            emailFactories,
             patchSetInfoFactory,
             messageId,
             postUpdateContext.getNotify(changeId),
@@ -153,7 +158,7 @@
    */
   private static class AsyncSender implements Runnable, RequestContext {
     private final IdentifiedUser user;
-    private final ReplacePatchSetSender.Factory replacePatchSetFactory;
+    private final EmailFactories emailFactories;
     private final PatchSetInfoFactory patchSetInfoFactory;
     private final MessageId messageId;
     private final NotifyResolver.Result notify;
@@ -172,7 +177,7 @@
 
     AsyncSender(
         IdentifiedUser user,
-        ReplacePatchSetSender.Factory replacePatchSetFactory,
+        EmailFactories emailFactories,
         PatchSetInfoFactory patchSetInfoFactory,
         MessageId messageId,
         NotifyResolver.Result notify,
@@ -188,7 +193,7 @@
         ObjectId preUpdateMetaId,
         Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
       this.user = user;
-      this.replacePatchSetFactory = replacePatchSetFactory;
+      this.emailFactories = emailFactories;
       this.patchSetInfoFactory = patchSetInfoFactory;
       this.messageId = messageId;
       this.notify = notify;
@@ -208,22 +213,26 @@
     @Override
     public void run() {
       try {
-        ReplacePatchSetSender emailSender =
-            replacePatchSetFactory.create(
+        ReplacePatchSetChangeEmailDecorator replacePatchSetEmail =
+            emailFactories.createReplacePatchSetChangeEmail(
                 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();
+        replacePatchSetEmail.addReviewers(reviewers);
+        replacePatchSetEmail.addExtraCC(extraCcs);
+        replacePatchSetEmail.addOutdatedApproval(outdatedApprovals);
+        ChangeEmail changeEmail =
+            emailFactories.createChangeEmail(projectName, changeId, replacePatchSetEmail);
+        changeEmail.setPatchSet(patchSet, patchSetInfoFactory.get(projectName, patchSet));
+        changeEmail.setChangeMessage(message, timestamp);
+        OutgoingEmail outgoingEmail =
+            emailFactories.createOutgoingEmail(NEW_PATCHSET_ADDED, changeEmail);
+        outgoingEmail.setFrom(user.getAccountId());
+        outgoingEmail.setNotify(notify);
+        outgoingEmail.setMessageId(messageId);
+        outgoingEmail.send();
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Cannot send email for new patch set %s", patchSet.id());
       }
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index a9886c7..27e68a8 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.change;
 
 import static com.google.gerrit.server.CommentsUtil.COMMENT_ORDER;
+import static com.google.gerrit.server.mail.EmailFactories.COMMENTS_ADDED;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
@@ -28,9 +29,12 @@
 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.EmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
+import com.google.gerrit.server.mail.send.CommentChangeEmailDecorator;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.util.LabelVote;
@@ -84,7 +88,7 @@
   EmailReviewComments(
       @SendEmailExecutor ExecutorService executor,
       PatchSetInfoFactory patchSetInfoFactory,
-      CommentSender.Factory commentSenderFactory,
+      EmailFactories emailFactories,
       ThreadLocalRequestContext requestContext,
       MessageIdGenerator messageIdGenerator,
       @Assisted PostUpdateContext postUpdateContext,
@@ -118,7 +122,7 @@
     this.asyncSender =
         new AsyncSender(
             requestContext,
-            commentSenderFactory,
+            emailFactories,
             patchSetInfoFactory,
             postUpdateContext.getUser().asIdentifiedUser(),
             messageId,
@@ -149,7 +153,7 @@
   // 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 EmailFactories emailFactories;
     private final PatchSetInfoFactory patchSetInfoFactory;
     private final IdentifiedUser user;
     private final MessageId messageId;
@@ -168,7 +172,7 @@
 
     AsyncSender(
         ThreadLocalRequestContext requestContext,
-        CommentSender.Factory commentSenderFactory,
+        EmailFactories emailFactories,
         PatchSetInfoFactory patchSetInfoFactory,
         IdentifiedUser user,
         MessageId messageId,
@@ -184,7 +188,7 @@
         ImmutableList<LabelVote> labels,
         Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
       this.requestContext = requestContext;
-      this.commentSenderFactory = commentSenderFactory;
+      this.emailFactories = emailFactories;
       this.patchSetInfoFactory = patchSetInfoFactory;
       this.user = user;
       this.messageId = messageId;
@@ -205,18 +209,22 @@
     public void run() {
       RequestContext old = requestContext.setContext(this);
       try {
-        CommentSender emailSender =
-            commentSenderFactory.create(
+        CommentChangeEmailDecorator commentChangeEmail =
+            emailFactories.createCommentChangeEmail(
                 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();
+        commentChangeEmail.setComments(comments);
+        commentChangeEmail.setPatchSetComment(patchSetComment);
+        commentChangeEmail.setLabels(labels);
+        ChangeEmail changeEmail =
+            emailFactories.createChangeEmail(projectName, changeId, commentChangeEmail);
+        changeEmail.setPatchSet(patchSet, patchSetInfoFactory.get(projectName, patchSet));
+        changeEmail.setChangeMessage(message, timestamp);
+        OutgoingEmail outgoingEmail =
+            emailFactories.createOutgoingEmail(COMMENTS_ADDED, changeEmail);
+        outgoingEmail.setFrom(user.getAccountId());
+        outgoingEmail.setNotify(notify);
+        outgoingEmail.setMessageId(messageId);
+        outgoingEmail.send();
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.id());
       } finally {
diff --git a/java/com/google/gerrit/server/change/FileContentUtil.java b/java/com/google/gerrit/server/change/FileContentUtil.java
index c54b902..4c0eb69 100644
--- a/java/com/google/gerrit/server/change/FileContentUtil.java
+++ b/java/com/google/gerrit/server/change/FileContentUtil.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.gerrit.server.project.ProjectState;
@@ -39,6 +40,7 @@
 import java.util.zip.ZipOutputStream;
 import org.eclipse.jgit.errors.LargeObjectException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectLoader;
@@ -60,11 +62,15 @@
 
   private final GitRepositoryManager repoManager;
   private final FileTypeRegistry registry;
+  private final long maxFileSizeBytes;
 
   @Inject
-  FileContentUtil(GitRepositoryManager repoManager, FileTypeRegistry ftr) {
+  FileContentUtil(
+      GitRepositoryManager repoManager, FileTypeRegistry ftr, @GerritServerConfig Config config) {
     this.repoManager = repoManager;
     this.registry = ftr;
+    long maxFileSizeDownload = config.getLong("change", null, "maxFileSizeDownload", 0);
+    this.maxFileSizeBytes = maxFileSizeDownload > 0 ? maxFileSizeDownload : Long.MAX_VALUE;
   }
 
   /**
@@ -117,6 +123,7 @@
         }
 
         ObjectLoader obj = repo.open(id, OBJ_BLOB);
+        checkMaxFileSizeBytes(obj);
         byte[] raw;
         try {
           raw = obj.getCachedBytes(MAX_SIZE);
@@ -154,7 +161,7 @@
 
   public BinaryResult downloadContent(
       ProjectState project, ObjectId revstr, String path, @Nullable Integer parent)
-      throws ResourceNotFoundException, IOException {
+      throws ResourceNotFoundException, IOException, BadRequestException {
     try (Repository repo = openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       String suffix = "new";
@@ -179,6 +186,8 @@
 
         ObjectId id = tw.getObjectId(0);
         ObjectLoader obj = repo.open(id, OBJ_BLOB);
+        checkMaxFileSizeBytes(obj);
+
         byte[] raw;
         try {
           raw = obj.getCachedBytes(MAX_SIZE);
@@ -194,6 +203,16 @@
     }
   }
 
+  private void checkMaxFileSizeBytes(ObjectLoader obj) throws BadRequestException {
+    if (obj.getSize() > this.maxFileSizeBytes) {
+      throw new BadRequestException(
+          String.format(
+              "File too big. File size: %d bytes. Configured 'maxFileSizeDownload' limit: %d"
+                  + " bytes.",
+              obj.getSize(), this.maxFileSizeBytes));
+    }
+  }
+
   private BinaryResult wrapBlob(
       String path,
       final ObjectLoader obj,
diff --git a/java/com/google/gerrit/server/change/IncludedIn.java b/java/com/google/gerrit/server/change/IncludedIn.java
index 94498d7..bc6579e 100644
--- a/java/com/google/gerrit/server/change/IncludedIn.java
+++ b/java/com/google/gerrit/server/change/IncludedIn.java
@@ -82,8 +82,8 @@
       }
 
       RefDatabase refDb = r.getRefDatabase();
-      Collection<Ref> tags = refDb.getRefsByPrefix(Constants.R_TAGS);
-      Collection<Ref> branches = refDb.getRefsByPrefix(Constants.R_HEADS);
+      List<Ref> tags = refDb.getRefsByPrefix(Constants.R_TAGS);
+      List<Ref> branches = refDb.getRefsByPrefix(Constants.R_HEADS);
       List<Ref> allTagsAndBranches = Lists.newArrayListWithCapacity(tags.size() + branches.size());
       allTagsAndBranches.addAll(tags);
       allTagsAndBranches.addAll(branches);
@@ -123,7 +123,7 @@
    * @param project specific Gerrit project.
    * @param inputRefs a list of branches (in short name) as strings
    */
-  private Collection<String> filterReadableRefs(
+  private ImmutableList<String> filterReadableRefs(
       Project.NameKey project, ImmutableList<Ref> inputRefs)
       throws IOException, PermissionBackendException {
     PermissionBackend.ForProject perm = permissionBackend.currentUser().project(project);
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index 5555ba6..c72c7a0 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -214,7 +214,8 @@
       boolean standard,
       boolean detailed)
       throws PermissionBackendException {
-    Map<String, LabelWithStatus> labels = initLabels(accountLoader, cd, labelTypes, standard);
+    Map<String, LabelWithStatus> labels =
+        initLabels(accountLoader, cd, labelTypes, /* includeAccountInfo= */ standard || detailed);
     setAllApprovals(accountLoader, cd, labels, detailed);
 
     for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
@@ -222,7 +223,7 @@
       if (!type.isPresent()) {
         continue;
       }
-      if (standard) {
+      if (standard || detailed) {
         for (PatchSetApproval psa : cd.currentApprovals()) {
           if (type.get().matches(psa)) {
             short val = psa.value();
@@ -373,7 +374,10 @@
   }
 
   private Map<String, LabelWithStatus> initLabels(
-      AccountLoader accountLoader, ChangeData cd, LabelTypes labelTypes, boolean standard) {
+      AccountLoader accountLoader,
+      ChangeData cd,
+      LabelTypes labelTypes,
+      boolean includeAccountInfo) {
     Map<String, LabelWithStatus> labels = new TreeMap<>(labelTypes.nameComparator());
     for (SubmitRecord rec : submitRecords(cd)) {
       if (rec.labels == null) {
@@ -383,7 +387,7 @@
         LabelWithStatus p = labels.get(r.label);
         if (p == null || p.status().compareTo(r.status) < 0) {
           LabelInfo n = new LabelInfo();
-          if (standard) {
+          if (includeAccountInfo) {
             switch (r.status) {
               case OK:
                 n.approved = accountLoader.get(r.appliedBy);
diff --git a/java/com/google/gerrit/server/change/ModifyReviewersEmail.java b/java/com/google/gerrit/server/change/ModifyReviewersEmail.java
index cb747f6..8f1fc1e 100644
--- a/java/com/google/gerrit/server/change/ModifyReviewersEmail.java
+++ b/java/com/google/gerrit/server/change/ModifyReviewersEmail.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.change;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.mail.EmailFactories.REVIEW_REQUESTED;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
@@ -24,8 +25,11 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.mail.EmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ModifyReviewerSender;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
+import com.google.gerrit.server.mail.send.StartReviewChangeEmailDecorator;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
@@ -36,16 +40,16 @@
 public class ModifyReviewersEmail {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final ModifyReviewerSender.Factory addReviewerSenderFactory;
+  private final EmailFactories emailFactories;
   private final ExecutorService sendEmailsExecutor;
   private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   ModifyReviewersEmail(
-      ModifyReviewerSender.Factory addReviewerSenderFactory,
+      EmailFactories emailFactories,
       @SendEmailExecutor ExecutorService sendEmailsExecutor,
       MessageIdGenerator messageIdGenerator) {
-    this.addReviewerSenderFactory = addReviewerSenderFactory;
+    this.emailFactories = emailFactories;
     this.sendEmailsExecutor = sendEmailsExecutor;
     this.messageIdGenerator = messageIdGenerator;
   }
@@ -90,20 +94,24 @@
         sendEmailsExecutor.submit(
             () -> {
               try {
-                ModifyReviewerSender emailSender =
-                    addReviewerSenderFactory.create(projectNameKey, cId);
-                emailSender.setNotify(notify);
-                emailSender.setFrom(userId);
-                emailSender.addReviewers(immutableToMail);
-                emailSender.addReviewersByEmail(immutableAddedByEmail);
-                emailSender.addExtraCC(immutableToCopy);
-                emailSender.addExtraCCByEmail(immutableCopiedByEmail);
-                emailSender.addRemovedReviewers(immutableToRemove);
-                emailSender.addRemovedByEmailReviewers(immutableRemovedByEmail);
-                emailSender.setMessageId(
+                StartReviewChangeEmailDecorator startReviewEmail =
+                    emailFactories.createStartReviewChangeEmail();
+                startReviewEmail.addReviewers(immutableToMail);
+                startReviewEmail.addReviewersByEmail(immutableAddedByEmail);
+                startReviewEmail.addExtraCC(immutableToCopy);
+                startReviewEmail.addExtraCCByEmail(immutableCopiedByEmail);
+                startReviewEmail.addRemovedReviewers(immutableToRemove);
+                startReviewEmail.addRemovedByEmailReviewers(immutableRemovedByEmail);
+                ChangeEmail changeEmail =
+                    emailFactories.createChangeEmail(projectNameKey, cId, startReviewEmail);
+                OutgoingEmail outgoingEmail =
+                    emailFactories.createOutgoingEmail(REVIEW_REQUESTED, changeEmail);
+                outgoingEmail.setNotify(notify);
+                outgoingEmail.setFrom(userId);
+                outgoingEmail.setMessageId(
                     messageIdGenerator.fromChangeUpdate(
                         change.getProject(), change.currentPatchSetId()));
-                emailSender.send();
+                outgoingEmail.send();
               } catch (Exception err) {
                 logger.atSevere().withCause(err).log(
                     "Cannot send email to new reviewers of change %s", change.getId());
diff --git a/java/com/google/gerrit/server/change/ParentDataProvider.java b/java/com/google/gerrit/server/change/ParentDataProvider.java
new file mode 100644
index 0000000..ffa46764b
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ParentDataProvider.java
@@ -0,0 +1,128 @@
+// 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.change;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.MoreCollectors;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.ParentCommitData;
+import com.google.gerrit.entities.Project;
+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.List;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.ReachabilityChecker;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+@Singleton
+public class ParentDataProvider {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final Provider<InternalChangeQuery> queryProvider;
+
+  @Inject
+  public ParentDataProvider(Provider<InternalChangeQuery> queryProvider) {
+    this.queryProvider = queryProvider;
+  }
+
+  /**
+   * Returns data about a specific {@code revCommit}, specifically whether it's merged in a {@code
+   * targetBranch}, or if it's a patch-set commit of some Gerrit change otherwise. This can be used
+   * to get more information of parent commits of patch-sets.
+   */
+  public ParentCommitData get(
+      Project.NameKey project, Repository repo, ObjectId parentCommitId, String targetBranch) {
+    boolean inTargetBranch = isMergedInTargetBranch(project, repo, parentCommitId, targetBranch);
+    Optional<ParentCommitData> fromGerritChange =
+        getFromGerritChange(project, parentCommitId, targetBranch);
+    if (fromGerritChange.isEmpty()) {
+      return ParentCommitData.builder()
+          .branchName(Optional.of(targetBranch))
+          .commitId(Optional.of(parentCommitId))
+          .isMergedInTargetBranch(inTargetBranch)
+          .autoBuild();
+    }
+    return fromGerritChange
+        .map(f -> f.toBuilder().isMergedInTargetBranch(inTargetBranch).autoBuild())
+        .get();
+  }
+
+  /** Returns true if the parent commit {@code parentCommitId} is merged in the target branch. */
+  private boolean isMergedInTargetBranch(
+      Project.NameKey project, Repository repo, ObjectId parentCommitId, String targetBranch) {
+    try (RevWalk rw = new RevWalk(repo);
+        ObjectReader reader = repo.newObjectReader()) {
+      Ref targetBranchRef = repo.exactRef(targetBranch);
+      if (targetBranchRef == null) {
+        return false;
+      }
+      RevCommit parent = rw.parseCommit(parentCommitId);
+      RevCommit targetBranchCommit = rw.parseCommit(targetBranchRef.getObjectId());
+      ReachabilityChecker checker = reader.createReachabilityChecker(rw);
+      Optional<RevCommit> unreachable =
+          checker.areAllReachable(
+              ImmutableList.of(parent), ImmutableList.of(targetBranchCommit).stream());
+      return unreachable.isEmpty();
+    } catch (IOException e) {
+      logger.atWarning().withCause(e).log(
+          "Failed to check if parent commit %s (project: %s) is merged into target branch %s",
+          parentCommitId.name(), project, targetBranch);
+    }
+    return false;
+  }
+
+  /**
+   * Returns {@link ParentCommitData} if there is a change associated with {@code parentCommitId}.
+   */
+  private Optional<ParentCommitData> getFromGerritChange(
+      Project.NameKey project, ObjectId parentCommitId, String targetBranch) {
+    List<ChangeData> changeData = queryProvider.get().byCommit(parentCommitId.name());
+    if (changeData.size() > 1) {
+      logger.atWarning().log(
+          "Found more than one change associated with parent revision %s (project: %s). Found"
+              + " changes %s.",
+          parentCommitId.name(),
+          project.get(),
+          changeData.stream().map(ChangeData::getId).collect(ImmutableList.toImmutableList()));
+    }
+    if (changeData.size() != 1) {
+      return Optional.empty();
+    }
+    ChangeData singleData = changeData.get(0);
+    int patchSetNumber =
+        singleData.patchSets().stream()
+            .filter(p -> p.commitId().equals(parentCommitId))
+            .collect(MoreCollectors.onlyElement())
+            .number();
+    return Optional.of(
+        ParentCommitData.builder()
+            .branchName(Optional.of(targetBranch))
+            .commitId(Optional.of(parentCommitId))
+            .changeKey(Optional.of(singleData.change().getKey()))
+            .changeNumber(Optional.of(singleData.getId().get()))
+            .patchSetNumber(Optional.of(patchSetNumber))
+            .changeStatus(Optional.of(singleData.change().getStatus()))
+            .autoBuild());
+  }
+}
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index 4a09f84..854fd4e 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -43,6 +43,7 @@
 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.git.validators.TopicValidator;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.AutoMerger;
@@ -84,6 +85,7 @@
   private final PatchSetUtil psUtil;
   private final WorkInProgressStateChanged wipStateChanged;
   private final AutoMerger autoMerger;
+  private final TopicValidator topicValidator;
 
   // Assisted-injected fields.
   private final PatchSet.Id psId;
@@ -132,6 +134,7 @@
       ProjectCache projectCache,
       WorkInProgressStateChanged wipStateChanged,
       AutoMerger autoMerger,
+      TopicValidator topicValidator,
       @Assisted ChangeNotes notes,
       @Assisted PatchSet.Id psId,
       @Assisted ObjectId commitId) {
@@ -147,6 +150,7 @@
     this.projectCache = projectCache;
     this.wipStateChanged = wipStateChanged;
     this.autoMerger = autoMerger;
+    this.topicValidator = topicValidator;
 
     this.origNotes = notes;
     this.psId = psId;
@@ -307,7 +311,7 @@
     if (topic != null) {
       change.setTopic(topic);
       try {
-        update.setTopic(topic);
+        update.setTopic(topic, topicValidator);
       } catch (ValidationException ex) {
         throw new BadRequestException(ex.getMessage());
       }
diff --git a/java/com/google/gerrit/server/change/ReaddOwnerUtil.java b/java/com/google/gerrit/server/change/ReaddOwnerUtil.java
new file mode 100644
index 0000000..afbe30b
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ReaddOwnerUtil.java
@@ -0,0 +1,127 @@
+// 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.change;
+
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.InternalUser;
+import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.config.AttentionSetConfig;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ChangeQueryProcessor;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.util.AttentionSetUtil;
+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.util.List;
+import java.util.concurrent.TimeUnit;
+
+@Singleton
+public class ReaddOwnerUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final AttentionSetConfig cfg;
+  private final Provider<ChangeQueryProcessor> queryProvider;
+  private final ChangeQueryBuilder queryBuilder;
+  private final AddToAttentionSetOp.Factory opFactory;
+  private final ServiceUserClassifier serviceUserClassifier;
+  private final InternalUser internalUser;
+
+  @Inject
+  ReaddOwnerUtil(
+      AttentionSetConfig cfg,
+      Provider<ChangeQueryProcessor> queryProvider,
+      ChangeQueryBuilder queryBuilder,
+      AddToAttentionSetOp.Factory opFactory,
+      ServiceUserClassifier serviceUserClassifier,
+      InternalUser.Factory internalUserFactory) {
+    this.cfg = cfg;
+    this.queryProvider = queryProvider;
+    this.queryBuilder = queryBuilder;
+    this.opFactory = opFactory;
+    this.serviceUserClassifier = serviceUserClassifier;
+    internalUser = internalUserFactory.create();
+  }
+
+  public void readdOwnerForInactiveOpenChanges(BatchUpdate.Factory updateFactory) {
+    if (cfg.getReaddAfter() <= 0) {
+      logger.atWarning().log("readdOwnerAfter needs to be set to a positive value");
+      return;
+    }
+
+    try {
+      String query =
+          "status:new -is:wip -is:private age:"
+              + TimeUnit.MILLISECONDS.toMinutes(cfg.getReaddAfter())
+              + "m";
+
+      List<ChangeData> changesToAddOwner =
+          queryProvider.get().enforceVisibility(false).query(queryBuilder.parse(query)).entities();
+
+      ImmutableListMultimap.Builder<Project.NameKey, ChangeData> builder =
+          ImmutableListMultimap.builder();
+      for (ChangeData cd : changesToAddOwner) {
+        builder.put(cd.project(), cd);
+      }
+
+      ListMultimap<Project.NameKey, ChangeData> ownerAdds = builder.build();
+      int ownersAdded = 0;
+      for (Project.NameKey project : ownerAdds.keySet()) {
+        try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+          try (BatchUpdate bu = updateFactory.create(project, internalUser, TimeUtil.now())) {
+            for (ChangeData changeData : ownerAdds.get(project)) {
+              Account.Id ownerId = changeData.change().getOwner();
+              if (!inAttentionSet(changeData, ownerId)
+                  && !serviceUserClassifier.isServiceUser(ownerId)) {
+                logger.atFine().log(
+                    "Batch owner for add to AS of change %s in project %s",
+                    changeData.getId(), project.get());
+                bu.addOp(
+                    changeData.getId(), opFactory.create(ownerId, cfg.getReaddMessage(), true));
+                ownersAdded++;
+              }
+            }
+            bu.execute();
+          } catch (RestApiException | UpdateException e) {
+            logger.atSevere().withCause(e).log(
+                "Failed to readd owners for changes in project %s", project.get());
+          }
+        }
+      }
+      logger.atInfo().log("Auto-Added %d owners to changes", ownersAdded);
+    } catch (QueryParseException | StorageException e) {
+      logger.atSevere().withCause(e).log(
+          "Failed to query inactive open changes for readding owners.");
+    }
+  }
+
+  private static boolean inAttentionSet(ChangeData changeData, Account.Id accountId) {
+    return AttentionSetUtil.additionsOnly(changeData.attentionSet()).stream()
+        .anyMatch(u -> u.account().equals(accountId));
+  }
+}
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index c1710b3..6ebc9b7 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.base.MoreObjects.firstNonNull;
 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.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
@@ -56,12 +58,14 @@
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.diff.Sequence;
 import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.merge.MergeResult;
+import org.eclipse.jgit.merge.Merger;
 import org.eclipse.jgit.merge.ResolveMerger;
 import org.eclipse.jgit.merge.ThreeWayMerger;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -108,6 +112,7 @@
   private boolean storeCopiedVotes = true;
   private boolean matchAuthorToCommitterDate = false;
   private ImmutableListMultimap<String, String> validationOptions = ImmutableListMultimap.of();
+  private String mergeStrategy;
 
   private CodeReviewCommit rebasedCommit;
   private PatchSet.Id rebasedPatchSetId;
@@ -264,6 +269,11 @@
     return this;
   }
 
+  public RebaseChangeOp setMergeStrategy(String strategy) {
+    this.mergeStrategy = strategy;
+    return this;
+  }
+
   @Override
   public void updateRepo(RepoContext ctx)
       throws InvalidChangeOperationException,
@@ -393,6 +403,10 @@
     return rebasedCommit;
   }
 
+  public PatchSet getOriginalPatchSet() {
+    return originalPatchSet;
+  }
+
   public PatchSet.Id getPatchSetId() {
     checkState(rebasedPatchSetId != null, "getPatchSetId() only valid after updateRepo");
     return rebasedPatchSetId;
@@ -433,9 +447,14 @@
       throw new ResourceConflictException("Change is already up to date.");
     }
 
-    ThreeWayMerger merger =
-        newMergeUtil().newThreeWayMerger(ctx.getInserter(), ctx.getRepoView().getConfig());
-    merger.setBase(parentCommit);
+    MergeUtil mergeUtil = newMergeUtil();
+    String strategy =
+        firstNonNull(Strings.emptyToNull(mergeStrategy), mergeUtil.mergeStrategyName());
+
+    Merger merger = MergeUtil.newMerger(ctx.getInserter(), ctx.getRepoView().getConfig(), strategy);
+    if (merger instanceof ThreeWayMerger) {
+      ((ThreeWayMerger) merger).setBase(parentCommit);
+    }
 
     DirCache dc = DirCache.newInCore();
     if (allowConflicts && merger instanceof ResolveMerger) {
@@ -493,7 +512,11 @@
     if (committerIdent != null) {
       cb.setCommitter(committerIdent);
     } else {
-      cb.setCommitter(ctx.newCommitterIdent());
+      PersonIdent committerIdent =
+          Optional.ofNullable(original.getCommitterIdent())
+              .map(ident -> ctx.newCommitterIdent(ident.getEmailAddress(), ctx.getIdentifiedUser()))
+              .orElseGet(ctx::newCommitterIdent);
+      cb.setCommitter(committerIdent);
     }
     if (matchAuthorToCommitterDate) {
       cb.setAuthor(
diff --git a/java/com/google/gerrit/server/change/RebaseUtil.java b/java/com/google/gerrit/server/change/RebaseUtil.java
index 72ba870..ceb87e4 100644
--- a/java/com/google/gerrit/server/change/RebaseUtil.java
+++ b/java/com/google/gerrit/server/change/RebaseUtil.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.change;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
@@ -38,6 +39,7 @@
 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.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
@@ -45,6 +47,7 @@
 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.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -62,39 +65,38 @@
   private final Provider<PersonIdent> serverIdent;
   private final IdentifiedUser.GenericFactory userFactory;
   private final PermissionBackend permissionBackend;
-  private final ChangeResource.Factory changeResourceFactory;
   private final GitRepositoryManager repoManager;
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeNotes.Factory notesFactory;
   private final PatchSetUtil psUtil;
   private final RebaseChangeOp.Factory rebaseFactory;
+  private final Provider<CurrentUser> self;
 
   @Inject
   RebaseUtil(
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
       IdentifiedUser.GenericFactory userFactory,
       PermissionBackend permissionBackend,
-      ChangeResource.Factory changeResourceFactory,
       GitRepositoryManager repoManager,
       Provider<InternalChangeQuery> queryProvider,
       ChangeNotes.Factory notesFactory,
       PatchSetUtil psUtil,
-      RebaseChangeOp.Factory rebaseFactory) {
+      RebaseChangeOp.Factory rebaseFactory,
+      Provider<CurrentUser> self) {
     this.serverIdent = serverIdent;
     this.userFactory = userFactory;
     this.permissionBackend = permissionBackend;
-    this.changeResourceFactory = changeResourceFactory;
     this.repoManager = repoManager;
     this.queryProvider = queryProvider;
     this.notesFactory = notesFactory;
     this.psUtil = psUtil;
     this.rebaseFactory = rebaseFactory;
+    this.self = self;
   }
 
   /**
-   * Checks that the uploader has permissions to create a new patch set and creates a new {@link
-   * RevisionResource} that contains the uploader (aka the impersonated user) as the current user
-   * which can be used for {@link BatchUpdate} to do the rebase on behalf of the uploader.
+   * Checks that the uploader has permissions to create a new patch set as the current user which
+   * can be used for {@link BatchUpdate} to do the rebase on behalf of the uploader.
    *
    * <p>The following permissions are required for the uploader:
    *
@@ -137,10 +139,8 @@
    *
    * @param rsrc the revision resource that should be rebased
    * @param rebaseInput the request input containing options for the rebase
-   * @return revision resource that contains the uploader (aka the impersonated user) as the current
-   *     user which can be used for {@link BatchUpdate} to do the rebase on behalf of the uploader
    */
-  public RevisionResource onBehalfOf(RevisionResource rsrc, RebaseInput rebaseInput)
+  public void checkCanRebaseOnBehalfOf(RevisionResource rsrc, RebaseInput rebaseInput)
       throws IOException,
           PermissionBackendException,
           BadRequestException,
@@ -211,9 +211,6 @@
         }
       }
     }
-
-    return new RevisionResource(
-        changeResourceFactory.create(rsrc.getNotes(), uploader), rsrc.getPatchSet());
   }
 
   private void checkPermissionForUploader(
@@ -541,22 +538,77 @@
     return baseId;
   }
 
-  public RebaseChangeOp getRebaseOp(RevisionResource revRsrc, RebaseInput input, ObjectId baseRev) {
+  public RebaseChangeOp getRebaseOp(
+      RevWalk rw,
+      RevisionResource revRsrc,
+      RebaseInput input,
+      ObjectId baseRev,
+      IdentifiedUser rebaseAsUser)
+      throws ResourceConflictException, PermissionBackendException, IOException {
     return applyRebaseInputToOp(
-        rebaseFactory.create(revRsrc.getNotes(), revRsrc.getPatchSet(), baseRev), input);
+        rw,
+        rebaseFactory.create(revRsrc.getNotes(), revRsrc.getPatchSet(), baseRev),
+        input,
+        rebaseAsUser);
   }
 
   public RebaseChangeOp getRebaseOp(
-      RevisionResource revRsrc, RebaseInput input, Change.Id baseChange) {
+      RevWalk rw,
+      RevisionResource revRsrc,
+      RebaseInput input,
+      Change.Id baseChange,
+      IdentifiedUser rebaseAsUser)
+      throws ResourceConflictException, PermissionBackendException, IOException {
     return applyRebaseInputToOp(
-        rebaseFactory.create(revRsrc.getNotes(), revRsrc.getPatchSet(), baseChange), input);
+        rw,
+        rebaseFactory.create(revRsrc.getNotes(), revRsrc.getPatchSet(), baseChange),
+        input,
+        rebaseAsUser);
   }
 
-  private RebaseChangeOp applyRebaseInputToOp(RebaseChangeOp op, RebaseInput input) {
-    return op.setForceContentMerge(true)
-        .setAllowConflicts(input.allowConflicts)
-        .setValidationOptions(
-            ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions))
-        .setFireRevisionCreated(true);
+  private RebaseChangeOp applyRebaseInputToOp(
+      RevWalk rw, RebaseChangeOp op, RebaseInput input, IdentifiedUser rebaseAsUser)
+      throws ResourceConflictException, PermissionBackendException, IOException {
+    RebaseChangeOp rebaseChangeOp =
+        op.setForceContentMerge(true)
+            .setAllowConflicts(input.allowConflicts)
+            .setMergeStrategy(input.strategy)
+            .setValidationOptions(
+                ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions))
+            .setFireRevisionCreated(true);
+
+    String originalPatchSetCommitterEmail =
+        rw.parseCommit(rebaseChangeOp.getOriginalPatchSet().commitId())
+            .getCommitterIdent()
+            .getEmailAddress();
+
+    if (input.committerEmail != null) {
+      if (!self.get().hasSameAccountId(rebaseAsUser)
+          && !input.committerEmail.equals(rebaseAsUser.getAccount().preferredEmail())
+          && !input.committerEmail.equals(originalPatchSetCommitterEmail)
+          && !permissionBackend.currentUser().test(GlobalPermission.VIEW_SECONDARY_EMAILS)) {
+        throw new ResourceConflictException(
+            String.format(
+                "Cannot rebase using committer email '%s'. It can only be done using the "
+                    + "preferred email or the committer email of the uploader",
+                input.committerEmail));
+      }
+
+      ImmutableSet<String> emails = rebaseAsUser.getEmailAddresses();
+      if (!emails.contains(input.committerEmail)) {
+        throw new ResourceConflictException(
+            String.format(
+                "Cannot rebase using committer email '%s' as it is not a registered "
+                    + "email of the user on whose behalf the rebase operation is performed",
+                input.committerEmail));
+      }
+      rebaseChangeOp.setCommitterIdent(
+          new PersonIdent(
+              rebaseAsUser.getName(),
+              input.committerEmail,
+              TimeUtil.now(),
+              serverIdent.get().getZoneId()));
+    }
+    return rebaseChangeOp;
   }
 }
diff --git a/java/com/google/gerrit/server/change/RelatedChangesSorter.java b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
index f4b1a83c..2d93d4a 100644
--- a/java/com/google/gerrit/server/change/RelatedChangesSorter.java
+++ b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
@@ -175,7 +175,7 @@
                     .collect(toMap(e -> e.getKey().name(), e -> e.getValue().patchSet().id()))));
   }
 
-  private Collection<PatchSetData> walkAncestors(
+  private Set<PatchSetData> walkAncestors(
       ListMultimap<PatchSetData, PatchSetData> parents, PatchSetData start)
       throws PermissionBackendException {
     LinkedHashSet<PatchSetData> result = new LinkedHashSet<>();
diff --git a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
index 1d92521..5930f7a 100644
--- a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator.AttentionSetChange.USER_REMOVED;
 import static java.util.Objects.requireNonNull;
 
 import com.google.gerrit.entities.Account;
@@ -21,7 +22,6 @@
 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.RemoveFromAttentionSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -39,7 +39,6 @@
   }
 
   private final ChangeData.Factory changeDataFactory;
-  private final RemoveFromAttentionSetSender.Factory removeFromAttentionSetSender;
   private final AttentionSetEmail.Factory attentionSetEmailFactory;
 
   private final Account.Id attentionUserId;
@@ -58,13 +57,11 @@
   @Inject
   RemoveFromAttentionSetOp(
       ChangeData.Factory changeDataFactory,
-      RemoveFromAttentionSetSender.Factory removeFromAttentionSetSenderFactory,
       AttentionSetEmail.Factory attentionSetEmailFactory,
       @Assisted Account.Id attentionUserId,
       @Assisted String reason,
       @Assisted boolean notify) {
     this.changeDataFactory = changeDataFactory;
-    this.removeFromAttentionSetSender = removeFromAttentionSetSenderFactory;
     this.attentionSetEmailFactory = attentionSetEmailFactory;
     this.attentionUserId = requireNonNull(attentionUserId, "user");
     this.reason = requireNonNull(reason, "reason");
@@ -97,13 +94,6 @@
     if (!notify) {
       return;
     }
-    attentionSetEmailFactory
-        .create(
-            removeFromAttentionSetSender.create(ctx.getProject(), change.getId()),
-            ctx,
-            change,
-            reason,
-            attentionUserId)
-        .sendAsync();
+    attentionSetEmailFactory.create(USER_REMOVED, ctx, change, reason, attentionUserId).sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/change/ReviewerModifier.java b/java/com/google/gerrit/server/change/ReviewerModifier.java
index b5e0181..daa41bf 100644
--- a/java/com/google/gerrit/server/change/ReviewerModifier.java
+++ b/java/com/google/gerrit/server/change/ReviewerModifier.java
@@ -295,7 +295,9 @@
       return fail(input, FailureType.NOT_FOUND, e.getMessage());
     }
 
-    if (isValidReviewer(notes.getChange().getDest(), reviewerUser.getAccount())) {
+    // If the reviewer is removed, we do not have to perform the visibility check on the change
+    if (ReviewerState.REMOVED.equals(input.state)
+        || isValidReviewer(notes.getChange().getDest(), reviewerUser.getAccount())) {
       return new ReviewerModification(
           input,
           notes,
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index c4fd5be..2ab7e15 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -24,6 +24,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_FILES;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_ACCOUNTS;
 import static com.google.gerrit.extensions.client.ListChangesOption.DOWNLOAD_COMMANDS;
+import static com.google.gerrit.extensions.client.ListChangesOption.PARENTS;
 import static com.google.gerrit.extensions.client.ListChangesOption.PUSH_CERTIFICATES;
 import static com.google.gerrit.extensions.client.ListChangesOption.WEB_LINKS;
 import static com.google.gerrit.server.CommonConverters.toGitPerson;
@@ -34,6 +35,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ParentCommitData;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -43,12 +45,17 @@
 import com.google.gerrit.extensions.common.FetchInfo;
 import com.google.gerrit.extensions.common.PushCertificateInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.common.RevisionInfo.ParentInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.config.DownloadCommand;
 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.ResourceConflictException;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GpgException;
@@ -67,10 +74,12 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
@@ -87,6 +96,23 @@
     RevisionJson create(Iterable<ListChangesOption> options);
   }
 
+  @Singleton
+  private static class Metrics {
+    private final Timer0 parentDataLatency;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      parentDataLatency =
+          metricMaker.newTimer(
+              "http/server/rest_api/change_json/to_change_info_latency/parent_data_computation",
+              new Description(
+                      "Latency for computing parent data information in toRevisionInfo"
+                          + " invocations in RevisionJson")
+                  .setCumulative()
+                  .setUnit(Units.MILLISECONDS));
+    }
+  }
+
   private final MergeUtilFactory mergeUtilFactory;
   private final IdentifiedUser.GenericFactory userFactory;
   private final FileInfoJson fileInfoJson;
@@ -104,6 +130,8 @@
   private final AnonymousUser anonymous;
   private final GitRepositoryManager repoManager;
   private final PermissionBackend permissionBackend;
+  private final ParentDataProvider parentDataProvider;
+  private final Metrics metrics;
 
   @Inject
   RevisionJson(
@@ -123,6 +151,8 @@
       ChangeKindCache changeKindCache,
       GitRepositoryManager repoManager,
       PermissionBackend permissionBackend,
+      ParentDataProvider parentDataProvider,
+      Metrics metrics,
       @Assisted Iterable<ListChangesOption> options) {
     this.userProvider = userProvider;
     this.anonymous = anonymous;
@@ -140,6 +170,8 @@
     this.changeKindCache = changeKindCache;
     this.permissionBackend = permissionBackend;
     this.repoManager = repoManager;
+    this.parentDataProvider = parentDataProvider;
+    this.metrics = metrics;
     this.options = ImmutableSet.copyOf(options);
   }
 
@@ -169,7 +201,8 @@
       boolean addLinks,
       boolean fillCommit,
       String branchName,
-      String changeKey)
+      String changeKey,
+      int numericChangeId)
       throws IOException {
     CommitInfo info = new CommitInfo();
     if (fillCommit) {
@@ -184,7 +217,12 @@
     if (addLinks) {
       ImmutableList<WebLinkInfo> patchSetLinks =
           webLinks.getPatchSetLinks(
-              project, commit.name(), commit.getFullMessage(), branchName, changeKey);
+              project,
+              commit.name(),
+              commit.getFullMessage(),
+              branchName,
+              changeKey,
+              numericChangeId);
       info.webLinks = patchSetLinks.isEmpty() ? null : patchSetLinks;
       ImmutableList<WebLinkInfo> resolveConflictsLinks =
           webLinks.getResolveConflictsLinks(
@@ -288,6 +326,13 @@
     out._number = in.id().get();
     out.ref = in.refName();
     out.setCreated(in.createdOn());
+    if (in.branch().isPresent()) {
+      // set the per-patch-set branch if it exists
+      out.branch = in.branch().get();
+    } else if (in.number() == cd.patchSets().size()) {
+      // only set the per-change branch on this patch-set if this is the last patch-set
+      out.branch = cd.change().getDest().branch();
+    }
     out.uploader = accountLoader.get(in.uploader());
     if (!in.uploader().equals(in.realUploader())) {
       out.realUploader = accountLoader.get(in.realUploader());
@@ -305,11 +350,31 @@
       String rev = in.commitId().name();
       RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
       rw.parseBody(commit);
-      String branchName = cd.change().getDest().branch();
+      String branchName = out.branch;
       if (setCommit) {
         out.commit =
             getCommitInfo(
-                project, rw, commit, has(WEB_LINKS), fillCommit, branchName, c.getKey().get());
+                project,
+                rw,
+                commit,
+                has(WEB_LINKS),
+                fillCommit,
+                branchName,
+                c.getKey().get(),
+                c.getId().get());
+      }
+      if (has(PARENTS)) {
+        try (Timer0.Context ignored = metrics.parentDataLatency.start()) {
+          String targetBranch =
+              in.branch().isPresent() ? in.branch().get() : cd.change().getDest().branch();
+          List<ParentCommitData> parentData = new ArrayList<>();
+          for (RevCommit parent : commit.getParents()) {
+            ParentCommitData p =
+                parentDataProvider.get(project, repo, parent.getId(), targetBranch);
+            parentData.add(p);
+          }
+          out.parentsData = getParentInfo(parentData);
+        }
       }
       if (addFooters) {
         Ref ref = repo.exactRef(branchName);
@@ -380,4 +445,32 @@
   private RevWalk newRevWalk(@Nullable Repository repo) {
     return repo != null ? new RevWalk(repo) : null;
   }
+
+  private static List<ParentInfo> getParentInfo(List<ParentCommitData> parentsData) {
+    List<ParentInfo> result = new ArrayList<>();
+    for (ParentCommitData parentData : parentsData) {
+      ParentInfo parentInfo = new ParentInfo();
+      if (parentData.branchName().isPresent()) {
+        parentInfo.branchName = parentData.branchName().get();
+      }
+      if (parentData.commitId().isPresent()) {
+        parentInfo.commitId = parentData.commitId().get().name();
+      }
+      if (parentData.changeKey().isPresent()) {
+        parentInfo.changeId = parentData.changeKey().get().get();
+      }
+      if (parentData.changeNumber().isPresent()) {
+        parentInfo.changeNumber = parentData.changeNumber().get();
+      }
+      if (parentData.patchSetNumber().isPresent()) {
+        parentInfo.patchSetNumber = parentData.patchSetNumber().get();
+      }
+      if (parentData.changeStatus().isPresent()) {
+        parentInfo.changeStatus = parentData.changeStatus().get().name();
+      }
+      parentInfo.isMergedInTargetBranch = parentData.isMergedInTargetBranch();
+      result.add(parentInfo);
+    }
+    return result;
+  }
 }
diff --git a/java/com/google/gerrit/server/change/SetCustomKeyedValuesOp.java b/java/com/google/gerrit/server/change/SetCustomKeyedValuesOp.java
new file mode 100644
index 0000000..0810c447
--- /dev/null
+++ b/java/com/google/gerrit/server/change/SetCustomKeyedValuesOp.java
@@ -0,0 +1,155 @@
+// 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.change;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.change.CustomKeyedValuesUtil.extractCustomKeyedValues;
+import static com.google.gerrit.server.change.CustomKeyedValuesUtil.extractCustomKeys;
+import static com.google.gerrit.server.notedb.ChangeUpdate.MAX_CUSTOM_KEYED_VALUES;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.api.changes.CustomKeyedValuesInput;
+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.server.change.CustomKeyedValuesUtil.InvalidCustomKeyedValueException;
+import com.google.gerrit.server.extensions.events.CustomKeyedValuesEdited;
+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.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.PostUpdateContext;
+import com.google.gerrit.server.validators.CustomKeyedValueValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+public class SetCustomKeyedValuesOp implements BatchUpdateOp {
+  public interface Factory {
+    SetCustomKeyedValuesOp create(CustomKeyedValuesInput input);
+  }
+
+  private final PluginSetContext<CustomKeyedValueValidationListener> validationListeners;
+  private final CustomKeyedValuesEdited customKeyedValuesEdited;
+  private final CustomKeyedValuesInput input;
+
+  private boolean fireEvent = true;
+
+  private Change change;
+  private ImmutableMap<String, String> toAdd;
+  private ImmutableSet<String> toRemove;
+  private ImmutableMap<String, String> updatedCustomKeyedValues;
+
+  @Inject
+  SetCustomKeyedValuesOp(
+      PluginSetContext<CustomKeyedValueValidationListener> validationListeners,
+      CustomKeyedValuesEdited customKeyedValuesEdited,
+      @Assisted @Nullable CustomKeyedValuesInput input) {
+    this.validationListeners = validationListeners;
+    this.customKeyedValuesEdited = customKeyedValuesEdited;
+    this.input = input;
+  }
+
+  public SetCustomKeyedValuesOp setFireEvent(boolean fireEvent) {
+    this.fireEvent = fireEvent;
+    return this;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws AuthException, BadRequestException, MethodNotAllowedException, IOException {
+    if (input == null || (input.add == null && input.remove == null)) {
+      updatedCustomKeyedValues = ImmutableMap.of();
+      return false;
+    }
+
+    change = ctx.getChange();
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+    ChangeNotes notes = update.getNotes().load();
+
+    try {
+      ImmutableMap<String, String> existingCustomKeyedValues = notes.getCustomKeyedValues();
+      ImmutableMap<String, String> tryingToAdd = extractCustomKeyedValues(input.add);
+      ImmutableSet<String> tryingToRemove = extractCustomKeys(input.remove);
+
+      validationListeners.runEach(
+          l -> l.validateCustomKeyedValues(update.getChange(), tryingToAdd, tryingToRemove),
+          ValidationException.class);
+      Map<String, String> newValues = new HashMap<>(existingCustomKeyedValues);
+      Map<String, String> added = new HashMap<>();
+      // Do the removes before the additions so that adding a key with a value while
+      // removing the key consists of adding the key with that new value.
+      for (String key : tryingToRemove) {
+        if (!newValues.containsKey(key)) {
+          continue;
+        }
+        update.deleteCustomKeyedValue(key);
+        newValues.remove(key);
+      }
+      for (Map.Entry<String, String> add : tryingToAdd.entrySet()) {
+        if (newValues.containsKey(add.getKey())
+            && newValues.get(add.getKey()).equals(add.getValue())) {
+          continue;
+        }
+        update.addCustomKeyedValue(add.getKey(), add.getValue());
+        newValues.put(add.getKey(), add.getValue());
+        added.put(add.getKey(), add.getValue());
+      }
+      if (newValues.size() > MAX_CUSTOM_KEYED_VALUES) {
+        throw new ValidationException("Too many custom keyed values.");
+      }
+      toAdd = ImmutableMap.copyOf(added);
+      toRemove =
+          ImmutableSet.copyOf(
+              Sets.filter(tryingToRemove, k -> existingCustomKeyedValues.containsKey(k)));
+      updatedCustomKeyedValues = ImmutableMap.copyOf(newValues);
+      return true;
+    } catch (ValidationException | InvalidCustomKeyedValueException e) {
+      throw new BadRequestException(e.getMessage(), e);
+    }
+  }
+
+  @Override
+  public void postUpdate(PostUpdateContext ctx) {
+    if (updated() && fireEvent) {
+      customKeyedValuesEdited.fire(
+          ctx.getChangeData(change),
+          ctx.getAccount(),
+          updatedCustomKeyedValues,
+          toAdd,
+          toRemove,
+          ctx.getWhen());
+    }
+  }
+
+  public ImmutableMap<String, String> getUpdatedCustomKeyedValues() {
+    checkState(
+        updatedCustomKeyedValues != null,
+        "getUpdatedCustomKeyedValues() only valid after executing op");
+    return updatedCustomKeyedValues;
+  }
+
+  private boolean updated() {
+    return (toAdd != null && !toAdd.isEmpty()) || (toRemove != null && !toRemove.isEmpty());
+  }
+}
diff --git a/java/com/google/gerrit/server/change/SetTopicOp.java b/java/com/google/gerrit/server/change/SetTopicOp.java
index ee35d1d..58342e5 100644
--- a/java/com/google/gerrit/server/change/SetTopicOp.java
+++ b/java/com/google/gerrit/server/change/SetTopicOp.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.extensions.events.TopicEdited;
+import com.google.gerrit.server.git.validators.TopicValidator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -36,6 +37,7 @@
   private final String topic;
   private final TopicEdited topicEdited;
   private final ChangeMessagesUtil cmUtil;
+  private final TopicValidator topicValidator;
 
   private Change change;
   private String oldTopicName;
@@ -43,10 +45,14 @@
 
   @Inject
   public SetTopicOp(
-      TopicEdited topicEdited, ChangeMessagesUtil cmUtil, @Nullable @Assisted String topic) {
+      TopicEdited topicEdited,
+      ChangeMessagesUtil cmUtil,
+      @Nullable @Assisted String topic,
+      TopicValidator topicValidator) {
     this.topic = topic;
     this.topicEdited = topicEdited;
     this.cmUtil = cmUtil;
+    this.topicValidator = topicValidator;
   }
 
   @Override
@@ -69,7 +75,7 @@
     }
     change.setTopic(Strings.emptyToNull(newTopicName));
     try {
-      update.setTopic(change.getTopic());
+      update.setTopic(change.getTopic(), topicValidator);
     } catch (ValidationException ex) {
       throw new BadRequestException(ex.getMessage());
     }
diff --git a/java/com/google/gerrit/server/change/WalkSorter.java b/java/com/google/gerrit/server/change/WalkSorter.java
index 44a3d16..d2f7ede 100644
--- a/java/com/google/gerrit/server/change/WalkSorter.java
+++ b/java/com/google/gerrit/server/change/WalkSorter.java
@@ -207,7 +207,7 @@
       return 0;
     }
     c.add(done);
-    Collection<PatchSetData> psds = byCommit.get(c);
+    List<PatchSetData> psds = byCommit.get(c);
     if (!psds.isEmpty()) {
       result.addAll(psds);
       return 1;
diff --git a/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
index 7fe193e..2bd8d5f 100644
--- a/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
+++ b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.cache.proto.Cache.AllCommentContextProto;
 import com.google.gerrit.server.cache.proto.Cache.AllCommentContextProto.CommentContextProto;
@@ -177,15 +178,18 @@
     private final ChangeNotes.Factory notesFactory;
     private final CommentsUtil commentsUtil;
     private final CommentContextLoader.Factory factory;
+    private final DraftCommentsReader draftCommentsReader;
 
     @Inject
     Loader(
         CommentsUtil commentsUtil,
         ChangeNotes.Factory notesFactory,
-        CommentContextLoader.Factory factory) {
+        CommentContextLoader.Factory factory,
+        DraftCommentsReader draftCommentsReader) {
       this.commentsUtil = commentsUtil;
       this.notesFactory = notesFactory;
       this.factory = factory;
+      this.draftCommentsReader = draftCommentsReader;
     }
 
     /**
@@ -251,7 +255,7 @@
         throws IOException {
       ChangeNotes notes = notesFactory.createChecked(project, changeId);
       List<HumanComment> humanComments = commentsUtil.publishedHumanCommentsByChange(notes);
-      List<HumanComment> drafts = commentsUtil.draftByChange(notes);
+      List<HumanComment> drafts = draftCommentsReader.getDraftsByChangeForAllAuthors(notes);
       List<HumanComment> allComments =
           Streams.concat(humanComments.stream(), drafts.stream()).collect(Collectors.toList());
       CommentContextLoader loader = factory.create(project);
diff --git a/java/com/google/gerrit/server/config/AttentionSetConfig.java b/java/com/google/gerrit/server/config/AttentionSetConfig.java
new file mode 100644
index 0000000..19698ae
--- /dev/null
+++ b/java/com/google/gerrit/server/config/AttentionSetConfig.java
@@ -0,0 +1,75 @@
+// 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.config;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.server.config.ScheduleConfig.Schedule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class AttentionSetConfig {
+  private static final String SECTION = "attentionSet";
+  private static final String KEY_READD_AFTER = "readdOwnerAfter";
+  private static final String KEY_READD_MESSAGE = "readdOwnerMessage";
+  private static final String DEFAULT_READD_MESSAGE =
+      "Owner readded to attention-set due to inactivity, see "
+          + "${URL}\n"
+          + "\n"
+          + "If you do not want to be readded to the attention-set when the timer has counted down,"
+          + " set this change as WIP or private.";
+
+  private final DynamicItem<UrlFormatter> urlFormatter;
+  private final Optional<Schedule> schedule;
+  private final long readdAfter;
+  private final String readdMessage;
+
+  @Inject
+  AttentionSetConfig(@GerritServerConfig Config cfg, DynamicItem<UrlFormatter> urlFormatter) {
+    this.urlFormatter = urlFormatter;
+    schedule = ScheduleConfig.createSchedule(cfg, SECTION);
+    readdAfter = readReaddAfter(cfg);
+    readdMessage = readReaddMessage(cfg);
+  }
+
+  private long readReaddAfter(Config cfg) {
+    long readdAfter =
+        ConfigUtil.getTimeUnit(cfg, SECTION, null, KEY_READD_AFTER, 0, TimeUnit.MILLISECONDS);
+    return readdAfter >= 0 ? readdAfter : 0;
+  }
+
+  private String readReaddMessage(Config cfg) {
+    String readdMessage = cfg.getString(SECTION, null, KEY_READD_MESSAGE);
+    return Strings.isNullOrEmpty(readdMessage) ? DEFAULT_READD_MESSAGE : readdMessage;
+  }
+
+  public Optional<Schedule> getSchedule() {
+    return schedule;
+  }
+
+  public long getReaddAfter() {
+    return readdAfter;
+  }
+
+  public String getReaddMessage() {
+    String docUrl =
+        urlFormatter.get().getDocUrl("user-attention-set.html", "auto-readd-owner").orElse("");
+    return docUrl.isEmpty() ? readdMessage : readdMessage.replace("${URL}", docUrl);
+  }
+}
diff --git a/java/com/google/gerrit/server/config/CachedPreferences.java b/java/com/google/gerrit/server/config/CachedPreferences.java
index 388f58a..de28ea0 100644
--- a/java/com/google/gerrit/server/config/CachedPreferences.java
+++ b/java/com/google/gerrit/server/config/CachedPreferences.java
@@ -15,82 +15,155 @@
 package com.google.gerrit.server.config;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Function;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.proto.Entities.UserPreferences;
+import com.google.gerrit.server.cache.proto.Cache.CachedPreferencesProto;
 import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 
 /**
  * Container class for preferences serialized as Git-style config files. Keeps the values as {@link
- * String}s as they are immutable and thread-safe.
+ * CachedPreferencesProto}s as they are immutable and thread-safe.
+ *
+ * <p>The config string wrapped by this class might represent different structures. See {@link
+ * CachedPreferencesProto} for more details.
  */
 @AutoValue
 public abstract class CachedPreferences {
+  public static final CachedPreferences EMPTY =
+      fromCachedPreferencesProto(CachedPreferencesProto.getDefaultInstance());
 
-  public static CachedPreferences EMPTY = fromString("");
+  protected abstract CachedPreferencesProto config();
 
-  public abstract String config();
-
-  /** Returns a cache-able representation of the config. */
-  public static CachedPreferences fromConfig(Config cfg) {
-    return new AutoValue_CachedPreferences(cfg.toText());
+  public Optional<CachedPreferencesProto> nonEmptyConfig() {
+    return config().equals(EMPTY.config()) ? Optional.empty() : Optional.of(config());
   }
 
-  /**
-   * Returns a cache-able representation of the config. To be used only when constructing a {@link
-   * CachedPreferences} from a serialized, cached value.
-   */
-  public static CachedPreferences fromString(String cfg) {
-    return new AutoValue_CachedPreferences(cfg);
+  /** Returns a cache-able representation of the preferences proto. */
+  public static CachedPreferences fromUserPreferencesProto(UserPreferences proto) {
+    return fromCachedPreferencesProto(
+        CachedPreferencesProto.newBuilder().setUserPreferences(proto).build());
+  }
+
+  /** Returns a cache-able representation of the git config. */
+  public static CachedPreferences fromLegacyConfig(Config cfg) {
+    return fromCachedPreferencesProto(
+        CachedPreferencesProto.newBuilder().setLegacyGitConfig(cfg.toText()).build());
+  }
+
+  /** Returns a cache-able representation of the preferences proto. */
+  public static CachedPreferences fromCachedPreferencesProto(
+      @Nullable CachedPreferencesProto proto) {
+    if (proto != null) {
+      return new AutoValue_CachedPreferences(proto);
+    }
+    return EMPTY;
   }
 
   public static GeneralPreferencesInfo general(
       Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
-    try {
-      return PreferencesParserUtil.parseGeneralPreferences(
-          userPreferences.asConfig(), configOrNull(defaultPreferences), null);
-    } catch (ConfigInvalidException e) {
-      return GeneralPreferencesInfo.defaults();
-    }
-  }
-
-  public static EditPreferencesInfo edit(
-      Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
-    try {
-      return PreferencesParserUtil.parseEditPreferences(
-          userPreferences.asConfig(), configOrNull(defaultPreferences), null);
-    } catch (ConfigInvalidException e) {
-      return EditPreferencesInfo.defaults();
-    }
+    return getPreferences(
+        defaultPreferences,
+        userPreferences,
+        PreferencesParserUtil::parseGeneralPreferences,
+        p ->
+            UserPreferencesConverter.GeneralPreferencesInfoConverter.fromProto(
+                p.getGeneralPreferencesInfo()),
+        GeneralPreferencesInfo.defaults());
   }
 
   public static DiffPreferencesInfo diff(
       Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
-    try {
-      return PreferencesParserUtil.parseDiffPreferences(
-          userPreferences.asConfig(), configOrNull(defaultPreferences), null);
-    } catch (ConfigInvalidException e) {
-      return DiffPreferencesInfo.defaults();
-    }
+    return getPreferences(
+        defaultPreferences,
+        userPreferences,
+        PreferencesParserUtil::parseDiffPreferences,
+        p ->
+            UserPreferencesConverter.DiffPreferencesInfoConverter.fromProto(
+                p.getDiffPreferencesInfo()),
+        DiffPreferencesInfo.defaults());
+  }
+
+  public static EditPreferencesInfo edit(
+      Optional<CachedPreferences> defaultPreferences, CachedPreferences userPreferences) {
+    return getPreferences(
+        defaultPreferences,
+        userPreferences,
+        PreferencesParserUtil::parseEditPreferences,
+        p ->
+            UserPreferencesConverter.EditPreferencesInfoConverter.fromProto(
+                p.getEditPreferencesInfo()),
+        EditPreferencesInfo.defaults());
   }
 
   public Config asConfig() {
-    Config cfg = new Config();
     try {
-      cfg.fromText(config());
+      switch (config().getPreferencesCase()) {
+        case LEGACY_GIT_CONFIG:
+        // continue below
+        case PREFERENCES_NOT_SET:
+          Config cfg = new Config();
+          cfg.fromText(config().getLegacyGitConfig());
+          return cfg;
+        case USER_PREFERENCES:
+          break;
+      }
     } catch (ConfigInvalidException e) {
-      // Programmer error: We have parsed this config before and are unable to parse it now.
       throw new StorageException(e);
     }
-    return cfg;
+    throw new StorageException(
+        String.format(
+            "Cannot parse the given config as a CachedPreferencesProto proto. Got [%s]", config()));
+  }
+
+  public UserPreferences asUserPreferencesProto() {
+    if (config().hasUserPreferences()) {
+      return config().getUserPreferences();
+    }
+    throw new StorageException(
+        String.format(
+            "Cannot parse the given config as a UserPreferences proto. Got [%s]", config()));
   }
 
   @Nullable
   private static Config configOrNull(Optional<CachedPreferences> cachedPreferences) {
     return cachedPreferences.map(CachedPreferences::asConfig).orElse(null);
   }
+
+  @FunctionalInterface
+  private interface ComputePreferencesFn<PreferencesT> {
+    PreferencesT apply(Config cfg, @Nullable Config defaultCfg, @Nullable PreferencesT input)
+        throws ConfigInvalidException;
+  }
+
+  private static <PreferencesT> PreferencesT getPreferences(
+      Optional<CachedPreferences> defaultPreferences,
+      CachedPreferences userPreferences,
+      ComputePreferencesFn<PreferencesT> computePreferencesFn,
+      Function<UserPreferences, PreferencesT> fromUserPreferencesFn,
+      PreferencesT javaDefaults) {
+    try {
+      CachedPreferencesProto userPreferencesProto = userPreferences.config();
+      switch (userPreferencesProto.getPreferencesCase()) {
+        case USER_PREFERENCES:
+          PreferencesT pref =
+              fromUserPreferencesFn.apply(userPreferencesProto.getUserPreferences());
+          return computePreferencesFn.apply(new Config(), configOrNull(defaultPreferences), pref);
+        case LEGACY_GIT_CONFIG:
+          return computePreferencesFn.apply(
+              userPreferences.asConfig(), configOrNull(defaultPreferences), null);
+        case PREFERENCES_NOT_SET:
+          throw new ConfigInvalidException("Invalid config " + userPreferences);
+      }
+    } catch (ConfigInvalidException e) {
+      return javaDefaults;
+    }
+    return javaDefaults;
+  }
 }
diff --git a/java/com/google/gerrit/server/config/ConfigUtil.java b/java/com/google/gerrit/server/config/ConfigUtil.java
index 5d94255..22c3d99 100644
--- a/java/com/google/gerrit/server/config/ConfigUtil.java
+++ b/java/com/google/gerrit/server/config/ConfigUtil.java
@@ -17,6 +17,9 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
+import java.io.IOException;
 import java.lang.reflect.Field;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Modifier;
@@ -391,6 +394,41 @@
     return s;
   }
 
+  /**
+   * Update user config by applying the specified delta
+   *
+   * <p>As opposed to {@link com.google.gerrit.server.config.ConfigUtil#storeSection}, this method
+   * does not unset a variable that are set to default, because it is expected that the input {@code
+   * original} is the raw user config value (does not include the defaults)
+   *
+   * <p>To use this method with the proto config (see {@link
+   * CachedPreferences#asUserPreferencesProto()}), the caller can first convert the proto to a java
+   * class usign one of the {@link UserPreferencesConverter} classes.
+   *
+   * <p>Fields marked with final or transient modifiers are skipped.
+   *
+   * @param original the original current user config
+   * @param updateDelta instance of class with config values that need to be uplied to the original
+   *     config
+   */
+  @UsedAt(Project.GOOGLE)
+  public static <T> void updatePreferences(T original, T updateDelta) throws IOException {
+    try {
+      for (Field f : updateDelta.getClass().getDeclaredFields()) {
+        if (skipField(f)) {
+          continue;
+        }
+        f.setAccessible(true);
+        Object c = f.get(updateDelta);
+        if (c != null) {
+          f.set(original, c);
+        }
+      }
+    } catch (SecurityException | IllegalArgumentException | IllegalAccessException e) {
+      throw new IOException("cannot apply delta the original config", e);
+    }
+  }
+
   public static boolean skipField(Field field) {
     int modifiers = field.getModifiers();
     return Modifier.isFinal(modifiers) || Modifier.isTransient(modifiers);
diff --git a/java/com/google/gerrit/server/config/DefaultPreferencesCache.java b/java/com/google/gerrit/server/config/DefaultPreferencesCache.java
index 39adb48..28b9507 100644
--- a/java/com/google/gerrit/server/config/DefaultPreferencesCache.java
+++ b/java/com/google/gerrit/server/config/DefaultPreferencesCache.java
@@ -22,7 +22,7 @@
    * Static member to be returned when there is no default config. This prevents re-instantiating
    * many {@link CachedPreferences} in this case.
    */
-  CachedPreferences EMPTY = CachedPreferences.fromString("");
+  CachedPreferences EMPTY = CachedPreferences.EMPTY;
 
   /** Returns a cached instance of {@link CachedPreferences}. */
   CachedPreferences get();
diff --git a/java/com/google/gerrit/server/config/DefaultPreferencesCacheImpl.java b/java/com/google/gerrit/server/config/DefaultPreferencesCacheImpl.java
index f8156a7..854a5c9 100644
--- a/java/com/google/gerrit/server/config/DefaultPreferencesCacheImpl.java
+++ b/java/com/google/gerrit/server/config/DefaultPreferencesCacheImpl.java
@@ -99,7 +99,7 @@
       try (Repository allUsersRepo = repositoryManager.openRepository(allUsersName)) {
         VersionedDefaultPreferences versionedDefaultPreferences = new VersionedDefaultPreferences();
         versionedDefaultPreferences.load(allUsersName, allUsersRepo, key);
-        return CachedPreferences.fromConfig(versionedDefaultPreferences.getConfig());
+        return CachedPreferences.fromLegacyConfig(versionedDefaultPreferences.getConfig());
       }
     }
   }
diff --git a/java/com/google/gerrit/server/config/DownloadConfig.java b/java/com/google/gerrit/server/config/DownloadConfig.java
index 4fdbd4a..28baa1a 100644
--- a/java/com/google/gerrit/server/config/DownloadConfig.java
+++ b/java/com/google/gerrit/server/config/DownloadConfig.java
@@ -19,7 +19,6 @@
 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;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -41,6 +40,16 @@
  */
 @Singleton
 public class DownloadConfig {
+  /** Preferred method to download a change. */
+  public enum DownloadCommand {
+    PULL,
+    CHECKOUT,
+    CHERRY_PICK,
+    FORMAT_PATCH,
+    BRANCH,
+    RESET,
+  }
+
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final ImmutableSet<String> downloadSchemes;
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 4325ec4..fe82a88 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -44,6 +44,7 @@
 import com.google.gerrit.extensions.events.ChangeRestoredListener;
 import com.google.gerrit.extensions.events.ChangeRevertedListener;
 import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.events.CustomKeyedValuesEditedListener;
 import com.google.gerrit.extensions.events.GarbageCollectorListener;
 import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
@@ -80,6 +81,7 @@
 import com.google.gerrit.extensions.webui.TopMenu;
 import com.google.gerrit.extensions.webui.WebUiPlugin;
 import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.ChangeDraftUpdate;
 import com.google.gerrit.server.CmdLineParserModule;
 import com.google.gerrit.server.CreateGroupPermissionSyncer;
 import com.google.gerrit.server.DeadlineChecker;
@@ -88,10 +90,8 @@
 import com.google.gerrit.server.ExceptionHookImpl;
 import com.google.gerrit.server.ExternalUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PerformanceMetrics;
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.TraceRequestListener;
-import com.google.gerrit.server.account.AccountCacheImpl;
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountDeactivator;
 import com.google.gerrit.server.account.AccountExternalIdCreator;
@@ -106,9 +106,8 @@
 import com.google.gerrit.server.account.GroupIncludeCacheImpl;
 import com.google.gerrit.server.account.ServiceUserClassifierImpl;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
-import com.google.gerrit.server.account.externalids.ExternalIdCacheModule;
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
-import com.google.gerrit.server.account.externalids.ExternalIdUpsertPreprocessor;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdCacheModule;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.UniversalAuthBackend;
@@ -164,20 +163,21 @@
 import com.google.gerrit.server.index.change.ReindexAfterRefUpdate;
 import com.google.gerrit.server.logging.PerformanceLogger;
 import com.google.gerrit.server.mail.AutoReplyMailFilter;
-import com.google.gerrit.server.mail.EmailModule;
 import com.google.gerrit.server.mail.ListMailFilter;
 import com.google.gerrit.server.mail.MailFilter;
 import com.google.gerrit.server.mail.send.FromAddressGenerator;
 import com.google.gerrit.server.mail.send.FromAddressGeneratorProvider;
-import com.google.gerrit.server.mail.send.InboundEmailRejectionSender;
 import com.google.gerrit.server.mail.send.MailSoySauceModule;
 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.ChangeDraftNotesUpdate;
 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.DiffFileSizeValidator;
 import com.google.gerrit.server.patch.DiffOperationsImpl;
+import com.google.gerrit.server.patch.DiffValidator;
 import com.google.gerrit.server.patch.PatchListCacheImpl;
 import com.google.gerrit.server.patch.PatchScriptFactory;
 import com.google.gerrit.server.patch.PatchScriptFactoryForAutoFix;
@@ -200,14 +200,14 @@
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
 import com.google.gerrit.server.quota.QuotaEnforcer;
+import com.google.gerrit.server.restapi.RestModule;
 import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.restapi.change.SuggestReviewers;
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule.DefaultSubmitRuleModule;
 import com.google.gerrit.server.rules.IgnoreSelfApprovalRule.IgnoreSelfApprovalRuleModule;
-import com.google.gerrit.server.rules.PrologModule;
-import com.google.gerrit.server.rules.RulesCache;
 import com.google.gerrit.server.rules.SubmitRule;
+import com.google.gerrit.server.rules.prolog.PrologModule;
 import com.google.gerrit.server.ssh.SshAddressesModule;
 import com.google.gerrit.server.submit.ConfiguredSubscriptionGraphFactory;
 import com.google.gerrit.server.submit.GitModules;
@@ -222,10 +222,12 @@
 import com.google.gerrit.server.util.IdGenerator;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.server.validators.AccountActivationValidationListener;
+import com.google.gerrit.server.validators.CustomKeyedValueValidationListener;
 import com.google.gerrit.server.validators.GroupCreationValidationListener;
 import com.google.gerrit.server.validators.HashtagValidationListener;
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
+import com.google.gerrit.server.version.VersionInfoModule;
 import com.google.gitiles.blame.cache.BlameCache;
 import com.google.gitiles.blame.cache.BlameCacheImpl;
 import com.google.inject.Inject;
@@ -252,9 +254,7 @@
     bind(EmailExpander.class).toProvider(EmailExpanderProvider.class).in(SINGLETON);
 
     bind(IdGenerator.class);
-    bind(RulesCache.class);
     bind(BlameCache.class).to(BlameCacheImpl.class);
-    install(AccountCacheImpl.module());
     install(BatchUpdate.module());
     install(ChangeKindCacheImpl.module());
     install(ChangeFinder.module());
@@ -277,7 +277,6 @@
     install(new AccessControlModule());
     install(new AccountModule());
     install(new CmdLineParserModule());
-    install(new EmailModule());
     install(new ExternalIdCacheModule());
     install(new ExternalIdModule());
     install(new GitModule());
@@ -288,11 +287,13 @@
     install(new DefaultSubmitRuleModule());
     install(new IgnoreSelfApprovalRuleModule());
     install(new ReceiveCommitsModule());
+    install(new RestModule());
     install(new SshAddressesModule());
     install(new FileInfoJsonModule());
     install(ThreadLocalRequestContext.module());
     install(new ApprovalModule());
     install(new MailSoySauceModule());
+    install(new VersionInfoModule());
     install(new SkipCurrentRulesEvaluationOnClosedChangesModule());
 
     factory(CapabilityCollection.Factory.class);
@@ -308,7 +309,6 @@
     factory(PatchScriptFactoryForAutoFix.Factory.class);
     factory(ProjectState.Factory.class);
     factory(RevisionJson.Factory.class);
-    factory(InboundEmailRejectionSender.Factory.class);
     factory(ExternalUser.Factory.class);
     bind(PermissionCollection.Factory.class);
     bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
@@ -334,6 +334,7 @@
     DynamicSet.setOf(binder(), GerritConfigListener.class);
 
     bind(ChangeCleanupConfig.class);
+    bind(AttentionSetConfig.class);
     bind(AccountDeactivator.class);
 
     bind(ApprovalsUtil.class);
@@ -364,6 +365,7 @@
     DynamicSet.setOf(binder(), ChangeDeletedListener.class);
     DynamicSet.setOf(binder(), CommentAddedListener.class);
     DynamicSet.setOf(binder(), HashtagsEditedListener.class);
+    DynamicSet.setOf(binder(), CustomKeyedValuesEditedListener.class);
     DynamicSet.setOf(binder(), ChangeMergedListener.class);
     bind(ChangeMergedListener.class)
         .annotatedWith(Exports.named("CreateGroupPermissionSyncer"))
@@ -405,6 +407,8 @@
         .to(SubmitRequirementConfigValidator.class);
     DynamicSet.bind(binder(), CommitValidationListener.class).to(PrologRulesWarningValidator.class);
     DynamicSet.setOf(binder(), CommentValidator.class);
+    DynamicSet.setOf(binder(), DiffValidator.class);
+    DynamicSet.bind(binder(), DiffValidator.class).to(DiffFileSizeValidator.class);
     DynamicSet.setOf(binder(), ChangeMessageModifier.class);
     DynamicSet.setOf(binder(), RefOperationValidationListener.class);
     DynamicSet.setOf(binder(), OnSubmitValidationListener.class);
@@ -414,6 +418,7 @@
     DynamicSet.setOf(binder(), HashtagValidationListener.class);
     DynamicSet.setOf(binder(), OutgoingEmailValidationListener.class);
     DynamicSet.setOf(binder(), AccountActivationValidationListener.class);
+    DynamicSet.setOf(binder(), CustomKeyedValueValidationListener.class);
     DynamicItem.itemOf(binder(), AvatarProvider.class);
     DynamicSet.setOf(binder(), LifecycleListener.class);
     DynamicSet.setOf(binder(), TopMenu.class);
@@ -448,9 +453,6 @@
     DynamicSet.setOf(binder(), SubmitRequirement.class);
     DynamicSet.setOf(binder(), QuotaEnforcer.class);
     DynamicSet.setOf(binder(), PerformanceLogger.class);
-    if (cfg.getBoolean("tracing", "exportPerformanceMetrics", false)) {
-      DynamicSet.bind(binder(), PerformanceLogger.class).to(PerformanceMetrics.class);
-    }
     DynamicSet.setOf(binder(), RequestListener.class);
     DynamicSet.bind(binder(), RequestListener.class).to(TraceRequestListener.class);
     DynamicSet.setOf(binder(), ChangeETagComputation.class);
@@ -479,6 +481,7 @@
     bind(CommentValidator.class)
         .annotatedWith(Exports.named(CommentCumulativeSizeValidator.class.getSimpleName()))
         .to(CommentCumulativeSizeValidator.class);
+    bind(ChangeDraftUpdate.ChangeDraftUpdateFactory.class).to(ChangeDraftNotesUpdate.Factory.class);
 
     DynamicMap.mapOf(binder(), DynamicOptions.DynamicBean.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
@@ -513,7 +516,6 @@
     bind(ReloadPluginListener.class)
         .annotatedWith(UniqueAnnotations.create())
         .to(PluginConfigFactory.class);
-    DynamicMap.mapOf(binder(), ExternalIdUpsertPreprocessor.class);
 
     bind(AttentionSetObserver.class);
   }
diff --git a/java/com/google/gerrit/server/config/GerritServerConfig.java b/java/com/google/gerrit/server/config/GerritServerConfig.java
index ead0d63..484c1e9 100644
--- a/java/com/google/gerrit/server/config/GerritServerConfig.java
+++ b/java/com/google/gerrit/server/config/GerritServerConfig.java
@@ -16,8 +16,8 @@
 
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
-import com.google.inject.BindingAnnotation;
 import java.lang.annotation.Retention;
+import javax.inject.Qualifier;
 
 /**
  * Marker on {@link org.eclipse.jgit.lib.Config} holding {@code gerrit.config} .
@@ -26,5 +26,5 @@
  * Gerrit Code Review server.
  */
 @Retention(RUNTIME)
-@BindingAnnotation
+@Qualifier
 public @interface GerritServerConfig {}
diff --git a/java/com/google/gerrit/server/config/SysExecutorModule.java b/java/com/google/gerrit/server/config/SysExecutorModule.java
index ea45b12..79210a0 100644
--- a/java/com/google/gerrit/server/config/SysExecutorModule.java
+++ b/java/com/google/gerrit/server/config/SysExecutorModule.java
@@ -34,6 +34,7 @@
  * in {@code Daemon} or similar, not nested in another module. This ensures the module can be
  * swapped out for the googlesource.com implementation.
  */
+@SuppressWarnings("ProvidesMethodOutsideOfModule")
 public class SysExecutorModule extends AbstractModule {
   @Override
   protected void configure() {}
diff --git a/java/com/google/gerrit/server/config/UserPreferencesConverter.java b/java/com/google/gerrit/server/config/UserPreferencesConverter.java
new file mode 100644
index 0000000..4a052d7
--- /dev/null
+++ b/java/com/google/gerrit/server/config/UserPreferencesConverter.java
@@ -0,0 +1,345 @@
+// 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.config;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.EditPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.MenuItem;
+import com.google.gerrit.proto.Entities.UserPreferences;
+import com.google.protobuf.Message;
+import com.google.protobuf.ProtocolMessageEnum;
+import java.util.function.Function;
+
+/**
+ * Converters for user preferences data classes
+ *
+ * <p>Upstream, we use java representations of the preference classes. Internally, we store proto
+ * equivalents in Spanner.
+ */
+public final class UserPreferencesConverter {
+  public static final class GeneralPreferencesInfoConverter {
+    public static UserPreferences.GeneralPreferencesInfo toProto(GeneralPreferencesInfo info) {
+      UserPreferences.GeneralPreferencesInfo.Builder builder =
+          UserPreferences.GeneralPreferencesInfo.newBuilder();
+      builder = setIfNotNull(builder, builder::setChangesPerPage, info.changesPerPage);
+      builder = setIfNotNull(builder, builder::setDownloadScheme, info.downloadScheme);
+      builder =
+          setEnumIfNotNull(
+              builder,
+              builder::setTheme,
+              UserPreferences.GeneralPreferencesInfo.Theme::valueOf,
+              info.theme);
+      builder =
+          setEnumIfNotNull(
+              builder,
+              builder::setDateFormat,
+              UserPreferences.GeneralPreferencesInfo.DateFormat::valueOf,
+              info.dateFormat);
+      builder =
+          setEnumIfNotNull(
+              builder,
+              builder::setTimeFormat,
+              UserPreferences.GeneralPreferencesInfo.TimeFormat::valueOf,
+              info.timeFormat);
+      builder = setIfNotNull(builder, builder::setExpandInlineDiffs, info.expandInlineDiffs);
+      builder =
+          setIfNotNull(
+              builder, builder::setRelativeDateInChangeTable, info.relativeDateInChangeTable);
+      builder =
+          setEnumIfNotNull(
+              builder,
+              builder::setDiffView,
+              UserPreferences.GeneralPreferencesInfo.DiffView::valueOf,
+              info.diffView);
+      builder = setIfNotNull(builder, builder::setSizeBarInChangeTable, info.sizeBarInChangeTable);
+      builder =
+          setIfNotNull(builder, builder::setLegacycidInChangeTable, info.legacycidInChangeTable);
+      builder =
+          setIfNotNull(builder, builder::setMuteCommonPathPrefixes, info.muteCommonPathPrefixes);
+      builder = setIfNotNull(builder, builder::setSignedOffBy, info.signedOffBy);
+      builder =
+          setEnumIfNotNull(
+              builder,
+              builder::setEmailStrategy,
+              UserPreferences.GeneralPreferencesInfo.EmailStrategy::valueOf,
+              info.emailStrategy);
+      builder =
+          setEnumIfNotNull(
+              builder,
+              builder::setEmailFormat,
+              UserPreferences.GeneralPreferencesInfo.EmailFormat::valueOf,
+              info.emailFormat);
+      builder =
+          setEnumIfNotNull(
+              builder,
+              builder::setDefaultBaseForMerges,
+              UserPreferences.GeneralPreferencesInfo.DefaultBase::valueOf,
+              info.defaultBaseForMerges);
+      builder =
+          setIfNotNull(builder, builder::setPublishCommentsOnPush, info.publishCommentsOnPush);
+      builder =
+          setIfNotNull(
+              builder, builder::setDisableKeyboardShortcuts, info.disableKeyboardShortcuts);
+      builder =
+          setIfNotNull(
+              builder, builder::setDisableTokenHighlighting, info.disableTokenHighlighting);
+      builder =
+          setIfNotNull(builder, builder::setWorkInProgressByDefault, info.workInProgressByDefault);
+      if (info.my != null) {
+        builder =
+            builder.addAllMyMenuItems(
+                info.my.stream().map(i -> menuItemToProto(i)).collect(toImmutableList()));
+      }
+      if (info.changeTable != null) {
+        builder = builder.addAllChangeTable(info.changeTable);
+      }
+      builder =
+          setIfNotNull(
+              builder, builder::setAllowBrowserNotifications, info.allowBrowserNotifications);
+      builder = setIfNotNull(builder, builder::setDiffPageSidebar, info.diffPageSidebar);
+      return builder.build();
+    }
+
+    public static GeneralPreferencesInfo fromProto(UserPreferences.GeneralPreferencesInfo proto) {
+      GeneralPreferencesInfo res = new GeneralPreferencesInfo();
+      res.changesPerPage = proto.hasChangesPerPage() ? proto.getChangesPerPage() : null;
+      res.downloadScheme = proto.hasDownloadScheme() ? proto.getDownloadScheme() : null;
+      res.theme =
+          proto.hasTheme() ? GeneralPreferencesInfo.Theme.valueOf(proto.getTheme().name()) : null;
+      res.dateFormat =
+          proto.hasDateFormat()
+              ? GeneralPreferencesInfo.DateFormat.valueOf(proto.getDateFormat().name())
+              : null;
+      res.timeFormat =
+          proto.hasTimeFormat()
+              ? GeneralPreferencesInfo.TimeFormat.valueOf(proto.getTimeFormat().name())
+              : null;
+      res.expandInlineDiffs = proto.hasExpandInlineDiffs() ? proto.getExpandInlineDiffs() : null;
+      res.relativeDateInChangeTable =
+          proto.hasRelativeDateInChangeTable() ? proto.getRelativeDateInChangeTable() : null;
+      res.diffView =
+          proto.hasDiffView()
+              ? GeneralPreferencesInfo.DiffView.valueOf(proto.getDiffView().name())
+              : null;
+      res.sizeBarInChangeTable =
+          proto.hasSizeBarInChangeTable() ? proto.getSizeBarInChangeTable() : null;
+      res.legacycidInChangeTable =
+          proto.hasLegacycidInChangeTable() ? proto.getLegacycidInChangeTable() : null;
+      res.muteCommonPathPrefixes =
+          proto.hasMuteCommonPathPrefixes() ? proto.getMuteCommonPathPrefixes() : null;
+      res.signedOffBy = proto.hasSignedOffBy() ? proto.getSignedOffBy() : null;
+      res.emailStrategy =
+          proto.hasEmailStrategy()
+              ? GeneralPreferencesInfo.EmailStrategy.valueOf(proto.getEmailStrategy().name())
+              : null;
+      res.emailFormat =
+          proto.hasEmailFormat()
+              ? GeneralPreferencesInfo.EmailFormat.valueOf(proto.getEmailFormat().name())
+              : null;
+      res.defaultBaseForMerges =
+          proto.hasDefaultBaseForMerges()
+              ? GeneralPreferencesInfo.DefaultBase.valueOf(proto.getDefaultBaseForMerges().name())
+              : null;
+      res.publishCommentsOnPush =
+          proto.hasPublishCommentsOnPush() ? proto.getPublishCommentsOnPush() : null;
+      res.disableKeyboardShortcuts =
+          proto.hasDisableKeyboardShortcuts() ? proto.getDisableKeyboardShortcuts() : null;
+      res.disableTokenHighlighting =
+          proto.hasDisableTokenHighlighting() ? proto.getDisableTokenHighlighting() : null;
+      res.workInProgressByDefault =
+          proto.hasWorkInProgressByDefault() ? proto.getWorkInProgressByDefault() : null;
+      res.my =
+          proto.getMyMenuItemsCount() != 0
+              ? proto.getMyMenuItemsList().stream()
+                  .map(p -> menuItemFromProto(p))
+                  .collect(toImmutableList())
+              : null;
+      res.changeTable = proto.getChangeTableCount() != 0 ? proto.getChangeTableList() : null;
+      res.allowBrowserNotifications =
+          proto.hasAllowBrowserNotifications() ? proto.getAllowBrowserNotifications() : null;
+      res.diffPageSidebar = proto.hasDiffPageSidebar() ? proto.getDiffPageSidebar() : null;
+      return res;
+    }
+
+    private static UserPreferences.GeneralPreferencesInfo.MenuItem menuItemToProto(
+        MenuItem javaItem) {
+      UserPreferences.GeneralPreferencesInfo.MenuItem.Builder builder =
+          UserPreferences.GeneralPreferencesInfo.MenuItem.newBuilder();
+      builder = setIfNotNull(builder, builder::setName, javaItem.name);
+      builder = setIfNotNull(builder, builder::setUrl, javaItem.url);
+      builder = setIfNotNull(builder, builder::setTarget, javaItem.target);
+      builder = setIfNotNull(builder, builder::setId, javaItem.id);
+      return builder.build();
+    }
+
+    private static MenuItem menuItemFromProto(
+        UserPreferences.GeneralPreferencesInfo.MenuItem proto) {
+      return new MenuItem(
+          proto.hasName() ? proto.getName() : null,
+          proto.hasUrl() ? proto.getUrl() : null,
+          proto.hasTarget() ? proto.getTarget() : null,
+          proto.hasId() ? proto.getId() : null);
+    }
+
+    private GeneralPreferencesInfoConverter() {}
+  }
+
+  public static final class DiffPreferencesInfoConverter {
+    public static UserPreferences.DiffPreferencesInfo toProto(DiffPreferencesInfo info) {
+      UserPreferences.DiffPreferencesInfo.Builder builder =
+          UserPreferences.DiffPreferencesInfo.newBuilder();
+      builder = setIfNotNull(builder, builder::setContext, info.context);
+      builder = setIfNotNull(builder, builder::setTabSize, info.tabSize);
+      builder = setIfNotNull(builder, builder::setFontSize, info.fontSize);
+      builder = setIfNotNull(builder, builder::setLineLength, info.lineLength);
+      builder = setIfNotNull(builder, builder::setCursorBlinkRate, info.cursorBlinkRate);
+      builder = setIfNotNull(builder, builder::setExpandAllComments, info.expandAllComments);
+      builder = setIfNotNull(builder, builder::setIntralineDifference, info.intralineDifference);
+      builder = setIfNotNull(builder, builder::setManualReview, info.manualReview);
+      builder = setIfNotNull(builder, builder::setShowLineEndings, info.showLineEndings);
+      builder = setIfNotNull(builder, builder::setShowTabs, info.showTabs);
+      builder = setIfNotNull(builder, builder::setShowWhitespaceErrors, info.showWhitespaceErrors);
+      builder = setIfNotNull(builder, builder::setSyntaxHighlighting, info.syntaxHighlighting);
+      builder = setIfNotNull(builder, builder::setHideTopMenu, info.hideTopMenu);
+      builder =
+          setIfNotNull(builder, builder::setAutoHideDiffTableHeader, info.autoHideDiffTableHeader);
+      builder = setIfNotNull(builder, builder::setHideLineNumbers, info.hideLineNumbers);
+      builder = setIfNotNull(builder, builder::setRenderEntireFile, info.renderEntireFile);
+      builder = setIfNotNull(builder, builder::setHideEmptyPane, info.hideEmptyPane);
+      builder = setIfNotNull(builder, builder::setMatchBrackets, info.matchBrackets);
+      builder = setIfNotNull(builder, builder::setLineWrapping, info.lineWrapping);
+      builder =
+          setEnumIfNotNull(
+              builder,
+              builder::setIgnoreWhitespace,
+              UserPreferences.DiffPreferencesInfo.Whitespace::valueOf,
+              info.ignoreWhitespace);
+      builder = setIfNotNull(builder, builder::setRetainHeader, info.retainHeader);
+      builder = setIfNotNull(builder, builder::setSkipDeleted, info.skipDeleted);
+      builder = setIfNotNull(builder, builder::setSkipUnchanged, info.skipUnchanged);
+      builder = setIfNotNull(builder, builder::setSkipUncommented, info.skipUncommented);
+      return builder.build();
+    }
+
+    public static DiffPreferencesInfo fromProto(UserPreferences.DiffPreferencesInfo proto) {
+      DiffPreferencesInfo res = new DiffPreferencesInfo();
+      res.context = proto.hasContext() ? proto.getContext() : null;
+      res.tabSize = proto.hasTabSize() ? proto.getTabSize() : null;
+      res.fontSize = proto.hasFontSize() ? proto.getFontSize() : null;
+      res.lineLength = proto.hasLineLength() ? proto.getLineLength() : null;
+      res.cursorBlinkRate = proto.hasCursorBlinkRate() ? proto.getCursorBlinkRate() : null;
+      res.expandAllComments = proto.hasExpandAllComments() ? proto.getExpandAllComments() : null;
+      res.intralineDifference =
+          proto.hasIntralineDifference() ? proto.getIntralineDifference() : null;
+      res.manualReview = proto.hasManualReview() ? proto.getManualReview() : null;
+      res.showLineEndings = proto.hasShowLineEndings() ? proto.getShowLineEndings() : null;
+      res.showTabs = proto.hasShowTabs() ? proto.getShowTabs() : null;
+      res.showWhitespaceErrors =
+          proto.hasShowWhitespaceErrors() ? proto.getShowWhitespaceErrors() : null;
+      res.syntaxHighlighting = proto.hasSyntaxHighlighting() ? proto.getSyntaxHighlighting() : null;
+      res.hideTopMenu = proto.hasHideTopMenu() ? proto.getHideTopMenu() : null;
+      res.autoHideDiffTableHeader =
+          proto.hasAutoHideDiffTableHeader() ? proto.getAutoHideDiffTableHeader() : null;
+      res.hideLineNumbers = proto.hasHideLineNumbers() ? proto.getHideLineNumbers() : null;
+      res.renderEntireFile = proto.hasRenderEntireFile() ? proto.getRenderEntireFile() : null;
+      res.hideEmptyPane = proto.hasHideEmptyPane() ? proto.getHideEmptyPane() : null;
+      res.matchBrackets = proto.hasMatchBrackets() ? proto.getMatchBrackets() : null;
+      res.lineWrapping = proto.hasLineWrapping() ? proto.getLineWrapping() : null;
+      res.ignoreWhitespace =
+          proto.hasIgnoreWhitespace()
+              ? DiffPreferencesInfo.Whitespace.valueOf(proto.getIgnoreWhitespace().name())
+              : null;
+      res.retainHeader = proto.hasRetainHeader() ? proto.getRetainHeader() : null;
+      res.skipDeleted = proto.hasSkipDeleted() ? proto.getSkipDeleted() : null;
+      res.skipUnchanged = proto.hasSkipUnchanged() ? proto.getSkipUnchanged() : null;
+      res.skipUncommented = proto.hasSkipUncommented() ? proto.getSkipUncommented() : null;
+      return res;
+    }
+
+    private DiffPreferencesInfoConverter() {}
+  }
+
+  public static final class EditPreferencesInfoConverter {
+    public static UserPreferences.EditPreferencesInfo toProto(EditPreferencesInfo info) {
+      UserPreferences.EditPreferencesInfo.Builder builder =
+          UserPreferences.EditPreferencesInfo.newBuilder();
+      builder = setIfNotNull(builder, builder::setTabSize, info.tabSize);
+      builder = setIfNotNull(builder, builder::setLineLength, info.lineLength);
+      builder = setIfNotNull(builder, builder::setIndentUnit, info.indentUnit);
+      builder = setIfNotNull(builder, builder::setCursorBlinkRate, info.cursorBlinkRate);
+      builder = setIfNotNull(builder, builder::setHideTopMenu, info.hideTopMenu);
+      builder = setIfNotNull(builder, builder::setShowTabs, info.showTabs);
+      builder = setIfNotNull(builder, builder::setShowWhitespaceErrors, info.showWhitespaceErrors);
+      builder = setIfNotNull(builder, builder::setSyntaxHighlighting, info.syntaxHighlighting);
+      builder = setIfNotNull(builder, builder::setHideLineNumbers, info.hideLineNumbers);
+      builder = setIfNotNull(builder, builder::setMatchBrackets, info.matchBrackets);
+      builder = setIfNotNull(builder, builder::setLineWrapping, info.lineWrapping);
+      builder = setIfNotNull(builder, builder::setIndentWithTabs, info.indentWithTabs);
+      builder = setIfNotNull(builder, builder::setAutoCloseBrackets, info.autoCloseBrackets);
+      builder = setIfNotNull(builder, builder::setShowBase, info.showBase);
+      return builder.build();
+    }
+
+    public static EditPreferencesInfo fromProto(UserPreferences.EditPreferencesInfo proto) {
+      EditPreferencesInfo res = new EditPreferencesInfo();
+      res.tabSize = proto.hasTabSize() ? proto.getTabSize() : null;
+      res.lineLength = proto.hasLineLength() ? proto.getLineLength() : null;
+      res.indentUnit = proto.hasIndentUnit() ? proto.getIndentUnit() : null;
+      res.cursorBlinkRate = proto.hasCursorBlinkRate() ? proto.getCursorBlinkRate() : null;
+      res.hideTopMenu = proto.hasHideTopMenu() ? proto.getHideTopMenu() : null;
+      res.showTabs = proto.hasShowTabs() ? proto.getShowTabs() : null;
+      res.showWhitespaceErrors =
+          proto.hasShowWhitespaceErrors() ? proto.getShowWhitespaceErrors() : null;
+      res.syntaxHighlighting = proto.hasSyntaxHighlighting() ? proto.getSyntaxHighlighting() : null;
+      res.hideLineNumbers = proto.hasHideLineNumbers() ? proto.getHideLineNumbers() : null;
+      res.matchBrackets = proto.hasMatchBrackets() ? proto.getMatchBrackets() : null;
+      res.lineWrapping = proto.hasLineWrapping() ? proto.getLineWrapping() : null;
+      res.indentWithTabs = proto.hasIndentWithTabs() ? proto.getIndentWithTabs() : null;
+      res.autoCloseBrackets = proto.hasAutoCloseBrackets() ? proto.getAutoCloseBrackets() : null;
+      res.showBase = proto.hasShowBase() ? proto.getShowBase() : null;
+      return res;
+    }
+
+    private EditPreferencesInfoConverter() {}
+  }
+
+  private static <ValueT, BuilderT extends Message.Builder> BuilderT setIfNotNull(
+      BuilderT builder, Function<ValueT, BuilderT> protoFieldSetterFn, ValueT javaField) {
+    if (javaField != null) {
+      return protoFieldSetterFn.apply(javaField);
+    }
+    return builder;
+  }
+
+  private static <
+          JavaEnumT extends Enum<?>,
+          ProtoEnumT extends ProtocolMessageEnum,
+          BuilderT extends Message.Builder>
+      BuilderT setEnumIfNotNull(
+          BuilderT builder,
+          Function<ProtoEnumT, BuilderT> protoFieldSetterFn,
+          Function<String, ProtoEnumT> protoEnumFromNameFn,
+          JavaEnumT javaEnum) {
+    if (javaEnum != null) {
+      return protoFieldSetterFn.apply(protoEnumFromNameFn.apply(javaEnum.name()));
+    }
+    return builder;
+  }
+
+  private UserPreferencesConverter() {}
+}
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
index 0e911b9..22c6995 100644
--- a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.documentation;
 
-import static com.vladsch.flexmark.profiles.pegdown.Extensions.ALL;
-import static com.vladsch.flexmark.profiles.pegdown.Extensions.HARDWRAPS;
-import static com.vladsch.flexmark.profiles.pegdown.Extensions.SUPPRESS_ALL_HTML;
+import static com.vladsch.flexmark.parser.PegdownExtensions.ALL;
+import static com.vladsch.flexmark.parser.PegdownExtensions.HARDWRAPS;
+import static com.vladsch.flexmark.parser.PegdownExtensions.SUPPRESS_ALL_HTML;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Strings;
@@ -26,7 +26,7 @@
 import com.vladsch.flexmark.ast.util.TextCollectingVisitor;
 import com.vladsch.flexmark.html.HtmlRenderer;
 import com.vladsch.flexmark.parser.Parser;
-import com.vladsch.flexmark.profiles.pegdown.PegdownOptionsAdapter;
+import com.vladsch.flexmark.profile.pegdown.PegdownOptionsAdapter;
 import com.vladsch.flexmark.util.ast.Block;
 import com.vladsch.flexmark.util.ast.Node;
 import com.vladsch.flexmark.util.data.MutableDataHolder;
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java b/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java
index 1875b64..04d2e5b 100644
--- a/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatterHeader.java
@@ -23,10 +23,9 @@
 import com.vladsch.flexmark.html.renderer.DelegatingNodeRendererFactory;
 import com.vladsch.flexmark.html.renderer.NodeRenderer;
 import com.vladsch.flexmark.html.renderer.NodeRendererContext;
-import com.vladsch.flexmark.html.renderer.NodeRendererFactory;
 import com.vladsch.flexmark.html.renderer.NodeRenderingHandler;
-import com.vladsch.flexmark.profiles.pegdown.Extensions;
-import com.vladsch.flexmark.profiles.pegdown.PegdownOptionsAdapter;
+import com.vladsch.flexmark.profile.pegdown.Extensions;
+import com.vladsch.flexmark.profile.pegdown.PegdownOptionsAdapter;
 import com.vladsch.flexmark.util.ast.Node;
 import com.vladsch.flexmark.util.data.DataHolder;
 import com.vladsch.flexmark.util.data.MutableDataHolder;
@@ -124,8 +123,8 @@
       }
 
       @Override
-      public Set<Class<? extends NodeRendererFactory>> getDelegates() {
-        Set<Class<? extends NodeRendererFactory>> delegates = new HashSet<>();
+      public Set<Class<?>> getDelegates() {
+        Set<Class<?>> delegates = new HashSet<>();
         delegates.add(AnchorLinkNodeRenderer.Factory.class);
         return delegates;
       }
diff --git a/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java b/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
index cd49ea6..093b87c 100644
--- a/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
+++ b/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
@@ -34,6 +34,7 @@
 import org.apache.lucene.search.Query;
 import org.apache.lucene.search.ScoreDoc;
 import org.apache.lucene.search.TopDocs;
+import org.apache.lucene.search.TotalHits;
 import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.IndexOutput;
@@ -84,10 +85,10 @@
       // We don't have much documentation, so we just use MAX_VALUE here and skip paging.
       TopDocs results = searcher.search(query, Integer.MAX_VALUE);
       ScoreDoc[] hits = results.scoreDocs;
-      long totalHits = results.totalHits;
+      TotalHits totalHits = results.totalHits;
 
       List<DocResult> out = new ArrayList<>();
-      for (int i = 0; i < totalHits; i++) {
+      for (int i = 0; i < totalHits.value; i++) {
         DocResult result = new DocResult();
         Document doc = searcher.doc(hits[i].doc);
         result.url = doc.get(Constants.URL_FIELD);
diff --git a/java/com/google/gerrit/server/edit/ChangeEdit.java b/java/com/google/gerrit/server/edit/ChangeEdit.java
index c652289..d7a3a11 100644
--- a/java/com/google/gerrit/server/edit/ChangeEdit.java
+++ b/java/com/google/gerrit/server/edit/ChangeEdit.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
@@ -53,6 +54,10 @@
     return editCommit;
   }
 
+  public ObjectId getEditCommitId() {
+    return editCommit.getId();
+  }
+
   public PatchSet getBasePatchSet() {
     return basePatchSet;
   }
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index 115a728..9cef539 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
@@ -37,7 +36,6 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.edit.tree.ChangeFileContentModification;
 import com.google.gerrit.server.edit.tree.DeleteFileModification;
 import com.google.gerrit.server.edit.tree.RenameFileModification;
@@ -105,7 +103,7 @@
   private final PatchSetUtil patchSetUtil;
   private final ProjectCache projectCache;
   private final NoteDbEdits noteDbEdits;
-  private final DynamicItem<UrlFormatter> urlFormatter;
+  private final ChangeUtil changeUtil;
 
   @Inject
   ChangeEditModifier(
@@ -117,7 +115,7 @@
       PatchSetUtil patchSetUtil,
       ProjectCache projectCache,
       GitReferenceUpdated gitReferenceUpdated,
-      DynamicItem<UrlFormatter> urlFormatter) {
+      ChangeUtil changeUtil) {
     this.currentUser = currentUser;
     this.permissionBackend = permissionBackend;
     this.zoneId = gerritIdent.getZoneId();
@@ -125,7 +123,7 @@
     this.patchSetUtil = patchSetUtil;
     this.projectCache = projectCache;
     noteDbEdits = new NoteDbEdits(gitReferenceUpdated, zoneId, indexer, currentUser);
-    this.urlFormatter = urlFormatter;
+    this.changeUtil = changeUtil;
   }
 
   /**
@@ -554,8 +552,7 @@
           "New commit message cannot be same as existing commit message");
     }
 
-    ChangeUtil.ensureChangeIdIsCorrect(
-        requireChangeId, currentChangeId, newCommitMessage, urlFormatter.get());
+    changeUtil.ensureChangeIdIsCorrect(requireChangeId, currentChangeId, newCommitMessage);
 
     return newCommitMessage;
   }
@@ -572,7 +569,7 @@
       builder.setTreeId(tree);
       builder.setParentIds(basePatchsetCommit.getParents());
       builder.setAuthor(basePatchsetCommit.getAuthorIdent());
-      builder.setCommitter(getCommitterIdent(timestamp));
+      builder.setCommitter(getCommitterIdent(basePatchsetCommit, timestamp));
       builder.setMessage(commitMessage);
       ObjectId newCommitId = objectInserter.insert(builder);
       objectInserter.flush();
@@ -580,9 +577,14 @@
     }
   }
 
-  private PersonIdent getCommitterIdent(Instant commitTimestamp) {
+  private PersonIdent getCommitterIdent(RevCommit basePatchsetCommit, Instant commitTimestamp) {
     IdentifiedUser user = currentUser.get().asIdentifiedUser();
-    return user.newCommitterIdent(commitTimestamp, zoneId);
+    return Optional.ofNullable(basePatchsetCommit.getCommitterIdent())
+        .map(
+            ident ->
+                user.newCommitterIdent(ident.getEmailAddress(), commitTimestamp, zoneId)
+                    .orElseGet(() -> user.newCommitterIdent(commitTimestamp, zoneId)))
+        .orElseGet(() -> user.newCommitterIdent(commitTimestamp, zoneId));
   }
 
   /**
diff --git a/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
index e7de322..ab41a37 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditUtil.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -249,7 +249,7 @@
     try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
       String refName = edit.getRefName();
       RefUpdate ru = repo.updateRef(refName, true);
-      ru.setExpectedOldObjectId(edit.getEditCommit());
+      ru.setExpectedOldObjectId(edit.getEditCommitId());
       ru.setForceUpdate(true);
       RefUpdate.Result result = ru.delete();
       switch (result) {
diff --git a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
index 96c6685..8839056 100644
--- a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
+++ b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.edit.tree;
 
+import static com.google.gerrit.entities.Patch.FileMode.EXECUTABLE_FILE;
+import static com.google.gerrit.entities.Patch.FileMode.REGULAR_FILE;
 import static java.util.Objects.requireNonNull;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
@@ -95,7 +97,7 @@
     }
 
     private boolean isValidGitFileMode(int gitFileMode) {
-      return (gitFileMode == 100755) || (gitFileMode == 100644);
+      return (gitFileMode == EXECUTABLE_FILE.getMode()) || (gitFileMode == REGULAR_FILE.getMode());
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/events/BatchRefUpdateEvent.java b/java/com/google/gerrit/server/events/BatchRefUpdateEvent.java
new file mode 100644
index 0000000..4898469
--- /dev/null
+++ b/java/com/google/gerrit/server/events/BatchRefUpdateEvent.java
@@ -0,0 +1,54 @@
+// 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.events;
+
+import com.google.common.base.Supplier;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.server.data.AccountAttribute;
+import com.google.gerrit.server.data.RefUpdateAttribute;
+import java.util.List;
+import java.util.stream.Collectors;
+
+public class BatchRefUpdateEvent extends ProjectEvent {
+
+  public static final String TYPE = "batch-ref-updated";
+
+  public Supplier<AccountAttribute> submitter;
+  public Supplier<List<RefUpdateAttribute>> refUpdates;
+  private NameKey projectName;
+
+  public BatchRefUpdateEvent() {
+    super(TYPE);
+  }
+
+  public BatchRefUpdateEvent(
+      NameKey projectName,
+      Supplier<List<RefUpdateAttribute>> refUpdates,
+      Supplier<AccountAttribute> updater) {
+    super(TYPE);
+    this.projectName = projectName;
+    this.refUpdates = refUpdates;
+    this.submitter = updater;
+  }
+
+  @Override
+  public NameKey getProjectNameKey() {
+    return projectName;
+  }
+
+  public List<String> getRefNames() {
+    return refUpdates.get().stream().map(ru -> ru.refName).collect(Collectors.toList());
+  }
+}
diff --git a/java/com/google/gerrit/server/events/CommentAddedEvent.java b/java/com/google/gerrit/server/events/CommentAddedEvent.java
index dbbebe8..d59ab08d2 100644
--- a/java/com/google/gerrit/server/events/CommentAddedEvent.java
+++ b/java/com/google/gerrit/server/events/CommentAddedEvent.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.data.ApprovalAttribute;
@@ -23,7 +24,7 @@
   static final String TYPE = "comment-added";
   public Supplier<AccountAttribute> author;
   public Supplier<ApprovalAttribute[]> approvals;
-  public String comment;
+  @Nullable public String comment;
 
   public CommentAddedEvent(Change change) {
     super(TYPE, change);
diff --git a/java/com/google/gerrit/server/events/CustomKeyedValuesChangedEvent.java b/java/com/google/gerrit/server/events/CustomKeyedValuesChangedEvent.java
new file mode 100644
index 0000000..353c830
--- /dev/null
+++ b/java/com/google/gerrit/server/events/CustomKeyedValuesChangedEvent.java
@@ -0,0 +1,32 @@
+// 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.events;
+
+import com.google.common.base.Supplier;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.data.AccountAttribute;
+import java.util.Map;
+
+public class CustomKeyedValuesChangedEvent extends ChangeEvent {
+  static final String TYPE = "custom-keyed-values-changed";
+  public Supplier<AccountAttribute> editor;
+  public Map<String, String> added;
+  public String[] removed;
+  public Map<String, String> customKeyedValues;
+
+  public CustomKeyedValuesChangedEvent(Change change) {
+    super(TYPE, change);
+  }
+}
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index da64cac..fdef955 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -17,6 +17,7 @@
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
@@ -176,7 +177,7 @@
   /** Add allReviewers to an existing {@link ChangeAttribute}. */
   public void addAllReviewers(
       ChangeAttribute a, ChangeNotes notes, AccountAttributeLoader accountLoader) {
-    Collection<Account.Id> reviewers = approvalsUtil.getReviewers(notes).all();
+    ImmutableSet<Account.Id> reviewers = approvalsUtil.getReviewers(notes).all();
     if (!reviewers.isEmpty()) {
       a.allReviewers = Lists.newArrayListWithCapacity(reviewers.size());
       for (Account.Id id : reviewers) {
diff --git a/java/com/google/gerrit/server/events/EventTypes.java b/java/com/google/gerrit/server/events/EventTypes.java
index c2c057c..4cc7198 100644
--- a/java/com/google/gerrit/server/events/EventTypes.java
+++ b/java/com/google/gerrit/server/events/EventTypes.java
@@ -23,12 +23,14 @@
   private static final Map<String, Class<?>> typesByString = new HashMap<>();
 
   static {
+    register(BatchRefUpdateEvent.TYPE, BatchRefUpdateEvent.class);
     register(ChangeAbandonedEvent.TYPE, ChangeAbandonedEvent.class);
     register(ChangeDeletedEvent.TYPE, ChangeDeletedEvent.class);
     register(ChangeMergedEvent.TYPE, ChangeMergedEvent.class);
     register(ChangeRestoredEvent.TYPE, ChangeRestoredEvent.class);
     register(CommentAddedEvent.TYPE, CommentAddedEvent.class);
     register(CommitReceivedEvent.TYPE, CommitReceivedEvent.class);
+    register(CustomKeyedValuesChangedEvent.TYPE, CustomKeyedValuesChangedEvent.class);
     register(HashtagsChangedEvent.TYPE, HashtagsChangedEvent.class);
     register(PatchSetCreatedEvent.TYPE, PatchSetCreatedEvent.class);
     register(PrivateStateChangedEvent.TYPE, PrivateStateChangedEvent.class);
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index b1929fe..136be8d 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
@@ -37,6 +38,8 @@
 import com.google.gerrit.extensions.events.ChangeMergedListener;
 import com.google.gerrit.extensions.events.ChangeRestoredListener;
 import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.events.CustomKeyedValuesEditedListener;
+import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.events.HashtagsEditedListener;
 import com.google.gerrit.extensions.events.HeadUpdatedListener;
@@ -50,10 +53,12 @@
 import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.data.ApprovalAttribute;
 import com.google.gerrit.server.data.ChangeAttribute;
 import com.google.gerrit.server.data.PatchSetAttribute;
+import com.google.gerrit.server.data.RefUpdateAttribute;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.plugincontext.PluginItemContext;
@@ -66,7 +71,10 @@
 import java.io.IOException;
 import java.util.Collection;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -81,7 +89,9 @@
         PrivateStateChangedListener,
         CommentAddedListener,
         GitReferenceUpdatedListener,
+        GitBatchRefUpdateListener,
         HashtagsEditedListener,
+        CustomKeyedValuesEditedListener,
         NewProjectCreatedListener,
         ReviewerAddedListener,
         ReviewerDeletedListener,
@@ -92,6 +102,13 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public static class StreamEventsApiListenerModule extends AbstractModule {
+
+    private Config config;
+
+    public StreamEventsApiListenerModule(Config config) {
+      this.config = config;
+    }
+
     @Override
     protected void configure() {
       DynamicSet.bind(binder(), ChangeAbandonedListener.class).to(StreamEventsApiListener.class);
@@ -99,9 +116,17 @@
       DynamicSet.bind(binder(), ChangeMergedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), ChangeRestoredListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), CommentAddedListener.class).to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
-          .to(StreamEventsApiListener.class);
+      if (config.getBoolean("event", "stream-events", "enableRefUpdatedEvents", true)) {
+        DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
+            .to(StreamEventsApiListener.class);
+      }
+      if (config.getBoolean("event", "stream-events", "enableBatchRefUpdatedEvents", false)) {
+        DynamicSet.bind(binder(), GitBatchRefUpdateListener.class)
+            .to(StreamEventsApiListener.class);
+      }
       DynamicSet.bind(binder(), HashtagsEditedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), CustomKeyedValuesEditedListener.class)
+          .to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), NewProjectCreatedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), PrivateStateChangedListener.class)
           .to(StreamEventsApiListener.class);
@@ -122,6 +147,7 @@
   private final GitRepositoryManager repoManager;
   private final PatchSetUtil psUtil;
   private final ChangeNotes.Factory changeNotesFactory;
+  private final boolean enableDraftCommentEvents;
   private final ChangeData.Factory changeDataFactory;
 
   @Inject
@@ -132,6 +158,7 @@
       GitRepositoryManager repoManager,
       PatchSetUtil psUtil,
       ChangeNotes.Factory changeNotesFactory,
+      @GerritServerConfig Config config,
       ChangeData.Factory changeDataFactory) {
     this.dispatcher = dispatcher;
     this.eventFactory = eventFactory;
@@ -139,6 +166,8 @@
     this.repoManager = repoManager;
     this.psUtil = psUtil;
     this.changeNotesFactory = changeNotesFactory;
+    this.enableDraftCommentEvents =
+        config.getBoolean("event", "stream-events", "enableDraftCommentEvents", false);
     this.changeDataFactory = changeDataFactory;
   }
 
@@ -238,9 +267,9 @@
   }
 
   @Nullable
-  String[] hashtagArray(Collection<String> hashtags) {
-    if (hashtags != null && !hashtags.isEmpty()) {
-      return Sets.newHashSet(hashtags).toArray(new String[hashtags.size()]);
+  String[] hashArray(Collection<String> collection) {
+    if (collection != null && !collection.isEmpty()) {
+      return Sets.newHashSet(collection).toArray(new String[collection.size()]);
     }
     return null;
   }
@@ -349,9 +378,28 @@
 
       event.change = changeAttributeSupplier(change, notes);
       event.editor = accountAttributeSupplier(ev.getWho());
-      event.hashtags = hashtagArray(ev.getHashtags());
-      event.added = hashtagArray(ev.getAddedHashtags());
-      event.removed = hashtagArray(ev.getRemovedHashtags());
+      event.hashtags = hashArray(ev.getHashtags());
+      event.added = hashArray(ev.getAddedHashtags());
+      event.removed = hashArray(ev.getRemovedHashtags());
+
+      dispatcher.run(d -> d.postEvent(change, event));
+    } catch (StorageException e) {
+      logger.atSevere().withCause(e).log("Failed to dispatch event");
+    }
+  }
+
+  @Override
+  public void onCustomKeyedValuesEdited(CustomKeyedValuesEditedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      CustomKeyedValuesChangedEvent event = new CustomKeyedValuesChangedEvent(change);
+
+      event.change = changeAttributeSupplier(change, notes);
+      event.editor = accountAttributeSupplier(ev.getWho());
+      event.customKeyedValues = ev.getCustomKeyedValues();
+      event.added = ev.getAddedCustomKeyedValues();
+      event.removed = hashArray(ev.getRemovedCustomKeys());
 
       dispatcher.run(d -> d.postEvent(change, event));
     } catch (StorageException e) {
@@ -373,7 +421,34 @@
                     ObjectId.fromString(ev.getOldObjectId()),
                     ObjectId.fromString(ev.getNewObjectId()),
                     refName));
-    dispatcher.run(d -> d.postEvent(refName, event));
+
+    if (enableDraftCommentEvents || !RefNames.isRefsDraftsComments(event.getRefName())) {
+      dispatcher.run(d -> d.postEvent(refName, event));
+    }
+  }
+
+  @Override
+  public void onGitBatchRefUpdate(GitBatchRefUpdateListener.Event ev) {
+    Project.NameKey projectName = Project.nameKey(ev.getProjectName());
+    Supplier<List<RefUpdateAttribute>> refUpdates =
+        Suppliers.memoize(
+            () ->
+                ev.getUpdatedRefs().stream()
+                    .filter(
+                        refUpdate ->
+                            enableDraftCommentEvents
+                                || !RefNames.isRefsDraftsComments(refUpdate.getRefName()))
+                    .map(
+                        ru ->
+                            eventFactory.asRefUpdateAttribute(
+                                ObjectId.fromString(ru.getOldObjectId()),
+                                ObjectId.fromString(ru.getNewObjectId()),
+                                BranchNameKey.create(ev.getProjectName(), ru.getRefName())))
+                    .collect(Collectors.toList()));
+
+    Supplier<AccountAttribute> submitterSupplier = accountAttributeSupplier(ev.getUpdater());
+    BatchRefUpdateEvent event = new BatchRefUpdateEvent(projectName, refUpdates, submitterSupplier);
+    dispatcher.run(d -> d.postEvent(projectName, event));
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java b/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java
index 227deb5..8de5db3 100644
--- a/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java
+++ b/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.experiments;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
@@ -57,6 +58,11 @@
   }
 
   @Override
+  public boolean isFeatureEnabled(String featureFlag, Project.NameKey project) {
+    return isFeatureEnabled(featureFlag);
+  }
+
+  @Override
   public ImmutableSet<String> getEnabledExperimentFeatures() {
     return enabledExperimentFeatures;
   }
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeatures.java b/java/com/google/gerrit/server/experiments/ExperimentFeatures.java
index dc9148a..fd885ed 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeatures.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeatures.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.experiments;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Project;
 
 /**
  * Features that can be enabled/disabled on Gerrit (e. g. experiments to research new behavior in
@@ -37,6 +38,12 @@
   boolean isFeatureEnabled(String featureFlag);
 
   /**
+   * Same {@link #isFeatureEnabled}, but takes into account {@code project}, when evaluating the
+   * experiment.
+   */
+  boolean isFeatureEnabled(String featureFlag, Project.NameKey project);
+
+  /**
    * Returns the names of the features that are enabled on Gerrit instance (either by default or via
    * gerrit.config).
    */
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index e294d55..32ec401 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -25,8 +25,4 @@
 
   /** Features, enabled by default in the current release. */
   public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES = ImmutableSet.of();
-
-  /** On BatchUpdate, do not await index completion before returning to the user */
-  public static String GERRIT_BACKEND_FEATURE_DO_NOT_AWAIT_CHANGE_INDEXING =
-      "GerritBackendFeature__do_not_await_change_indexing";
 }
diff --git a/java/com/google/gerrit/server/extensions/events/CommentAdded.java b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
index 79544f2..c6661bd 100644
--- a/java/com/google/gerrit/server/extensions/events/CommentAdded.java
+++ b/java/com/google/gerrit/server/extensions/events/CommentAdded.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;
@@ -54,7 +55,7 @@
       ChangeData changeData,
       PatchSet ps,
       AccountState author,
-      String comment,
+      @Nullable String comment,
       Map<String, Short> approvals,
       Map<String, Short> oldApprovals,
       Instant when) {
@@ -86,7 +87,7 @@
   /** Event to be fired when a comment or vote has been added to a change. */
   private static class Event extends AbstractRevisionEvent implements CommentAddedListener.Event {
 
-    private final String comment;
+    @Nullable private final String comment;
     private final Map<String, ApprovalInfo> approvals;
     private final Map<String, ApprovalInfo> oldApprovals;
 
@@ -94,7 +95,7 @@
         ChangeInfo change,
         RevisionInfo revision,
         AccountInfo author,
-        String comment,
+        @Nullable String comment,
         Map<String, ApprovalInfo> approvals,
         Map<String, ApprovalInfo> oldApprovals,
         Instant when) {
@@ -105,6 +106,7 @@
     }
 
     @Override
+    @Nullable
     public String getComment() {
       return comment;
     }
diff --git a/java/com/google/gerrit/server/extensions/events/CustomKeyedValuesEdited.java b/java/com/google/gerrit/server/extensions/events/CustomKeyedValuesEdited.java
new file mode 100644
index 0000000..949840a
--- /dev/null
+++ b/java/com/google/gerrit/server/extensions/events/CustomKeyedValuesEdited.java
@@ -0,0 +1,108 @@
+// 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.extensions.events;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
+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.CustomKeyedValuesEditedListener;
+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;
+
+/** Helper class to fire an event when the hashtags of a change has been edited. */
+@Singleton
+public class CustomKeyedValuesEdited {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final PluginSetContext<CustomKeyedValuesEditedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  public CustomKeyedValuesEdited(
+      PluginSetContext<CustomKeyedValuesEditedListener> listeners, EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(
+      ChangeData changeData,
+      AccountState editor,
+      ImmutableMap<String, String> customKeyedValues,
+      ImmutableMap<String, String> added,
+      ImmutableSet<String> removed,
+      Instant when) {
+    if (listeners.isEmpty()) {
+      return;
+    }
+    try {
+      Event event =
+          new Event(
+              util.changeInfo(changeData),
+              util.accountInfo(editor),
+              customKeyedValues,
+              added,
+              removed,
+              when);
+      listeners.runEach(l -> l.onCustomKeyedValuesEdited(event));
+    } catch (StorageException e) {
+      logger.atSevere().withCause(e).log("Couldn't fire event");
+    }
+  }
+
+  /** Event to be fired when the custom keyed values of a change has been edited. */
+  private static class Event extends AbstractChangeEvent
+      implements CustomKeyedValuesEditedListener.Event {
+
+    private ImmutableMap<String, String> updated;
+    private ImmutableMap<String, String> added;
+    private ImmutableSet<String> removed;
+
+    Event(
+        ChangeInfo change,
+        AccountInfo editor,
+        ImmutableMap<String, String> updated,
+        ImmutableMap<String, String> added,
+        ImmutableSet<String> removed,
+        Instant when) {
+      super(change, editor, when, NotifyHandling.ALL);
+      this.updated = updated;
+      this.added = added;
+      this.removed = removed;
+    }
+
+    @Override
+    public ImmutableMap<String, String> getCustomKeyedValues() {
+      return updated;
+    }
+
+    @Override
+    public ImmutableMap<String, String> getAddedCustomKeyedValues() {
+      return added;
+    }
+
+    @Override
+    public ImmutableSet<String> getRemovedCustomKeys() {
+      return removed;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index ffb6c66..8a7840b 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -15,9 +15,11 @@
 package com.google.gerrit.server.git;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.gerrit.server.mail.EmailFactories.CHANGE_REVERTED;
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -37,17 +39,19 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ValidationOptionsUtil;
 import com.google.gerrit.server.extensions.events.ChangeReverted;
+import com.google.gerrit.server.mail.EmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.RevertedSender;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 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;
@@ -67,6 +71,7 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -93,7 +98,7 @@
   private final ApprovalsUtil approvalsUtil;
   private final ChangeInserter.Factory changeInserterFactory;
   private final NotifyResolver notifyResolver;
-  private final RevertedSender.Factory revertedSenderFactory;
+  private final EmailFactories emailFactories;
   private final ChangeMessagesUtil cmUtil;
   private final ChangeNotes.Factory changeNotesFactory;
   private final ChangeReverted changeReverted;
@@ -108,7 +113,7 @@
       ApprovalsUtil approvalsUtil,
       ChangeInserter.Factory changeInserterFactory,
       NotifyResolver notifyResolver,
-      RevertedSender.Factory revertedSenderFactory,
+      EmailFactories emailFactories,
       ChangeMessagesUtil cmUtil,
       ChangeNotes.Factory changeNotesFactory,
       ChangeReverted changeReverted,
@@ -120,7 +125,7 @@
     this.approvalsUtil = approvalsUtil;
     this.changeInserterFactory = changeInserterFactory;
     this.notifyResolver = notifyResolver;
-    this.revertedSenderFactory = revertedSenderFactory;
+    this.emailFactories = emailFactories;
     this.cmUtil = cmUtil;
     this.changeNotesFactory = changeNotesFactory;
     this.changeReverted = changeReverted;
@@ -209,7 +214,7 @@
    * @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 parents of the new commit. Can be empty.
    * @param commitMessage for the new commit.
    * @param treeId of the content for the new commit.
    * @return the newly created commit.
@@ -219,16 +224,14 @@
       ObjectInserter oi,
       PersonIdent authorIdent,
       PersonIdent committerIdent,
-      @Nullable RevCommit parentCommit,
+      List<RevCommit> parents,
       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.setParentIds(parents.stream().map(RevCommit::getId).collect(Collectors.toList()));
     commit.setAuthor(authorIdent);
     commit.setCommitter(committerIdent);
     commit.setMessage(commitMessage);
@@ -291,7 +294,12 @@
     }
 
     return createCommitWithTree(
-        oi, authorIdent, committerIdent, commitToRevert, message, parentToCommitToRevert.getTree());
+        oi,
+        authorIdent,
+        committerIdent,
+        ImmutableList.of(commitToRevert),
+        message,
+        parentToCommitToRevert.getTree());
   }
 
   private Change.Id createRevertChangeFromCommit(
@@ -381,14 +389,19 @@
           ctx.getChangeData(changeNotesFactory.createChecked(ctx.getProject(), revertingChangeId));
       changeReverted.fire(revertedChange, revertingChange, ctx.getWhen());
       try {
-        RevertedSender emailSender =
-            revertedSenderFactory.create(ctx.getProject(), revertedChange.getId());
-        emailSender.setFrom(ctx.getAccountId());
-        emailSender.setNotify(ctx.getNotify(revertedChangeId));
-        emailSender.setMessageId(
+        ChangeEmail changeEmail =
+            emailFactories.createChangeEmail(
+                ctx.getProject(),
+                revertedChange.getId(),
+                emailFactories.createRevertedChangeEmail());
+        OutgoingEmail outgoingEmail =
+            emailFactories.createOutgoingEmail(CHANGE_REVERTED, changeEmail);
+        outgoingEmail.setFrom(ctx.getAccountId());
+        outgoingEmail.setNotify(ctx.getNotify(revertedChangeId));
+        outgoingEmail.setMessageId(
             messageIdGenerator.fromChangeUpdate(
                 ctx.getRepoView(), revertedChange.currentPatchSet().id()));
-        emailSender.send();
+        outgoingEmail.send();
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
             "Cannot send email for revert change %s", revertedChangeId);
diff --git a/java/com/google/gerrit/server/git/GroupCollector.java b/java/com/google/gerrit/server/git/GroupCollector.java
index 455b221..72d8bd9 100644
--- a/java/com/google/gerrit/server/git/GroupCollector.java
+++ b/java/com/google/gerrit/server/git/GroupCollector.java
@@ -168,7 +168,7 @@
     Set<String> parentGroupsNewInThisPush =
         Sets.newLinkedHashSetWithExpectedSize(interestingParents.size());
     for (RevCommit p : interestingParents) {
-      Collection<String> parentGroups = groups.get(p);
+      List<String> parentGroups = groups.get(p);
       if (parentGroups.isEmpty()) {
         throw new IllegalStateException(
             String.format("no group assigned to parent %s of commit %s", p.name(), c.name()));
@@ -270,7 +270,7 @@
     }
   }
 
-  private Iterable<String> resolveGroup(ObjectId forCommit, String group) throws IOException {
+  private ImmutableList<String> resolveGroup(ObjectId forCommit, String group) throws IOException {
     ObjectId id = parseGroup(forCommit, group);
     if (id != null) {
       PatchSet.Id psId = Iterables.getFirst(receivePackRefCache.patchSetIdsFromObjectId(id), null);
@@ -279,7 +279,7 @@
         // Group for existing patch set may be missing, e.g. if group has not
         // been migrated yet.
         if (groups != null && !groups.isEmpty()) {
-          return groups;
+          return ImmutableList.copyOf(groups);
         }
       }
     }
diff --git a/java/com/google/gerrit/server/git/LargeObjectException.java b/java/com/google/gerrit/server/git/LargeObjectException.java
index 04db42c..145b631 100644
--- a/java/com/google/gerrit/server/git/LargeObjectException.java
+++ b/java/com/google/gerrit/server/git/LargeObjectException.java
@@ -25,6 +25,10 @@
 
   private static final long serialVersionUID = 1L;
 
+  public LargeObjectException(String message) {
+    super(message);
+  }
+
   public LargeObjectException(String message, org.eclipse.jgit.errors.LargeObjectException cause) {
     super(message, cause);
   }
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index 5efdd9a..dcb7790 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -143,6 +143,7 @@
   private final boolean useContentMerge;
   private final boolean useRecursiveMerge;
   private final PluggableCommitMessageGenerator commitMessageGenerator;
+  private final ChangeUtil changeUtil;
 
   MergeUtil(
       @Provided @GerritServerConfig Config serverConfig,
@@ -150,6 +151,7 @@
       @Provided DynamicItem<UrlFormatter> urlFormatter,
       @Provided ApprovalsUtil approvalsUtil,
       @Provided PluggableCommitMessageGenerator commitMessageGenerator,
+      @Provided ChangeUtil changeUtil,
       ProjectState project) {
     this(
         serverConfig,
@@ -157,6 +159,7 @@
         urlFormatter,
         approvalsUtil,
         commitMessageGenerator,
+        changeUtil,
         project,
         project.is(BooleanProjectConfig.USE_CONTENT_MERGE));
   }
@@ -167,12 +170,14 @@
       @Provided DynamicItem<UrlFormatter> urlFormatter,
       @Provided ApprovalsUtil approvalsUtil,
       @Provided PluggableCommitMessageGenerator commitMessageGenerator,
+      @Provided ChangeUtil changeUtil,
       ProjectState project,
       boolean useContentMerge) {
     this.identifiedUserFactory = identifiedUserFactory;
     this.urlFormatter = urlFormatter;
     this.approvalsUtil = approvalsUtil;
     this.commitMessageGenerator = commitMessageGenerator;
+    this.changeUtil = changeUtil;
     this.project = project;
     this.useContentMerge = useContentMerge;
     this.useRecursiveMerge = useRecursiveMerge(serverConfig);
@@ -284,7 +289,6 @@
     return commit;
   }
 
-  @SuppressWarnings("resource") // TemporaryBuffer requires calling close before reading.
   public static ObjectId mergeWithConflicts(
       RevWalk rw,
       ObjectInserter ins,
@@ -295,6 +299,21 @@
       RevCommit theirs,
       Map<String, MergeResult<? extends Sequence>> mergeResults)
       throws IOException {
+    return mergeWithConflicts(rw, ins, dc, oursName, ours, theirsName, theirs, mergeResults, false);
+  }
+
+  @SuppressWarnings("resource") // TemporaryBuffer requires calling close before reading.
+  public static ObjectId mergeWithConflicts(
+      RevWalk rw,
+      ObjectInserter ins,
+      DirCache dc,
+      String oursName,
+      RevCommit ours,
+      String theirsName,
+      RevCommit theirs,
+      Map<String, MergeResult<? extends Sequence>> mergeResults,
+      boolean diff3Format)
+      throws IOException {
     rw.parseBody(ours);
     rw.parseBody(theirs);
     String oursMsg = ours.getShortMessage();
@@ -322,7 +341,11 @@
       try {
         // TODO(dborowitz): Respect inCoreLimit here.
         buf = new TemporaryBuffer.LocalFile(null, 10 * 1024 * 1024);
-        fmt.formatMerge(buf, p, "BASE", oursNameFormatted, theirsNameFormatted, UTF_8);
+        if (diff3Format) {
+          fmt.formatMergeDiff3(buf, p, "BASE", oursNameFormatted, theirsNameFormatted, UTF_8);
+        } else {
+          fmt.formatMerge(buf, p, "BASE", oursNameFormatted, theirsNameFormatted, UTF_8);
+        }
         buf.close(); // Flush file and close for writes, but leave available for reading.
 
         try (InputStream in = buf.openInputStream()) {
@@ -536,7 +559,7 @@
       msgbuf.append('\n');
     }
 
-    if (ChangeUtil.getChangeIdsFromFooter(n, urlFormatter.get()).isEmpty()) {
+    if (changeUtil.getChangeIdsFromFooter(n).isEmpty()) {
       msgbuf.append(FooterConstants.CHANGE_ID.getName());
       msgbuf.append(": ");
       msgbuf.append(c.getKey().get());
diff --git a/java/com/google/gerrit/server/git/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java
index 426f8db..c977a34 100644
--- a/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.git;
 
+import static com.google.gerrit.server.mail.EmailFactories.CHANGE_MERGED;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.flogger.FluentLogger;
@@ -26,8 +27,10 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.extensions.events.ChangeMerged;
-import com.google.gerrit.server.mail.send.MergedSender;
+import com.google.gerrit.server.mail.EmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -67,7 +70,7 @@
   private final RequestScopePropagator requestScopePropagator;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final ChangeMessagesUtil cmUtil;
-  private final MergedSender.Factory mergedSenderFactory;
+  private final EmailFactories emailFactories;
   private final PatchSetUtil psUtil;
   private final ExecutorService sendEmailExecutor;
   private final ChangeMerged changeMerged;
@@ -88,7 +91,7 @@
   MergedByPushOp(
       PatchSetInfoFactory patchSetInfoFactory,
       ChangeMessagesUtil cmUtil,
-      MergedSender.Factory mergedSenderFactory,
+      EmailFactories emailFactories,
       PatchSetUtil psUtil,
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       ChangeMerged changeMerged,
@@ -100,7 +103,7 @@
       @Assisted("mergeResultRevId") String mergeResultRevId) {
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.cmUtil = cmUtil;
-    this.mergedSenderFactory = mergedSenderFactory;
+    this.emailFactories = emailFactories;
     this.psUtil = psUtil;
     this.sendEmailExecutor = sendEmailExecutor;
     this.changeMerged = changeMerged;
@@ -188,16 +191,19 @@
                     try {
                       // The stickyApprovalDiff is always empty here since this is not supported
                       // for direct pushes.
-                      MergedSender emailSender =
-                          mergedSenderFactory.create(
+                      ChangeEmail changeEmail =
+                          emailFactories.createChangeEmail(
                               ctx.getProject(),
                               psId.changeId(),
-                              /* stickyApprovalDiff= */ Optional.empty());
-                      emailSender.setFrom(ctx.getAccountId());
-                      emailSender.setPatchSet(patchSet, info);
-                      emailSender.setMessageId(
+                              emailFactories.createMergedChangeEmail(
+                                  /* stickyApprovalDiff= */ Optional.empty()));
+                      changeEmail.setPatchSet(patchSet, info);
+                      OutgoingEmail outgoingEmail =
+                          emailFactories.createOutgoingEmail(CHANGE_MERGED, changeEmail);
+                      outgoingEmail.setFrom(ctx.getAccountId());
+                      outgoingEmail.setMessageId(
                           messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
-                      emailSender.send();
+                      outgoingEmail.send();
                     } catch (Exception e) {
                       logger.atSevere().withCause(e).log(
                           "Cannot send email for submitted patch set %s", psId);
diff --git a/java/com/google/gerrit/server/git/PureRevertCache.java b/java/com/google/gerrit/server/git/PureRevertCache.java
index 90eadf3..21da863 100644
--- a/java/com/google/gerrit/server/git/PureRevertCache.java
+++ b/java/com/google/gerrit/server/git/PureRevertCache.java
@@ -38,6 +38,7 @@
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
 import com.google.protobuf.ByteString;
+import com.google.protobuf.TextFormat;
 import java.io.IOException;
 import java.util.List;
 import java.util.concurrent.ExecutionException;
@@ -156,7 +157,10 @@
       try (TraceContext.TraceTimer ignored =
           TraceContext.newTimer(
               "Loading pure revert",
-              Metadata.builder().cacheKey(key.toString()).projectName(key.getProject()).build())) {
+              Metadata.builder()
+                  .cacheKey(TextFormat.shortDebugString(key))
+                  .projectName(key.getProject())
+                  .build())) {
         ObjectId original = ObjectIdConverter.create().fromByteString(key.getClaimedOriginal());
         ObjectId revert = ObjectIdConverter.create().fromByteString(key.getClaimedRevert());
         Project.NameKey project = Project.nameKey(key.getProject());
diff --git a/java/com/google/gerrit/server/git/TagCache.java b/java/com/google/gerrit/server/git/TagCache.java
index daf5ea5..d417089 100644
--- a/java/com/google/gerrit/server/git/TagCache.java
+++ b/java/com/google/gerrit/server/git/TagCache.java
@@ -34,7 +34,7 @@
       @Override
       protected void configure() {
         persist(CACHE_NAME, String.class, TagSetHolder.class)
-            .version(1)
+            .version(2)
             .keySerializer(StringCacheSerializer.INSTANCE)
             .valueSerializer(TagSetHolder.Serializer.INSTANCE);
         bind(TagCache.class);
diff --git a/java/com/google/gerrit/server/git/TagMatcher.java b/java/com/google/gerrit/server/git/TagMatcher.java
index f003b6f..6edc7f0 100644
--- a/java/com/google/gerrit/server/git/TagMatcher.java
+++ b/java/com/google/gerrit/server/git/TagMatcher.java
@@ -17,15 +17,15 @@
 import com.google.gerrit.server.git.TagSet.Tag;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.BitSet;
 import java.util.Collection;
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
+import org.roaringbitmap.RoaringBitmap;
 
 public class TagMatcher {
-  final BitSet mask = new BitSet();
+  final RoaringBitmap mask = new RoaringBitmap();
   final List<Ref> newRefs = new ArrayList<>();
   final List<LostRef> lostRefs = new ArrayList<>();
   final TagSetHolder holder;
diff --git a/java/com/google/gerrit/server/git/TagSet.java b/java/com/google/gerrit/server/git/TagSet.java
index 845b1b4..6888a79 100644
--- a/java/com/google/gerrit/server/git/TagSet.java
+++ b/java/com/google/gerrit/server/git/TagSet.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.git;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableSet;
@@ -26,8 +28,9 @@
 import com.google.gerrit.server.cache.proto.Cache.TagSetHolderProto.TagSetProto.TagProto;
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.protobuf.ByteString;
+import java.io.DataOutputStream;
 import java.io.IOException;
-import java.util.BitSet;
+import java.nio.ByteBuffer;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicReference;
@@ -40,8 +43,10 @@
 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.RevSort;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.roaringbitmap.RoaringBitmap;
 
 /**
  * Builds a set of tags, and tracks which tags are reachable from which non-tag, non-special refs.
@@ -67,7 +72,7 @@
 
   /**
    * refName => ref. CachedRef is a ref that has an integer identity, used for indexing into
-   * BitSets.
+   * RoaringBitmaps.
    */
   private final Map<String, CachedRef> refs;
 
@@ -145,7 +150,7 @@
         // The reference has not been moved. It can be used as-is.
         ObjectId savedObjectId = savedRef.get();
         if (currentRef.getObjectId().equals(savedObjectId)) {
-          m.mask.set(savedRef.flag);
+          m.mask.add(savedRef.flag);
           continue;
         }
 
@@ -162,7 +167,7 @@
           if (rw.isMergedInto(savedCommit, currentCommit)) {
             // Fast-forward. Safely update the reference in-place.
             savedRef.compareAndSet(savedObjectId, currentRef.getObjectId());
-            m.mask.set(savedRef.flag);
+            m.mask.add(savedRef.flag);
             continue;
           }
 
@@ -176,7 +181,7 @@
           RevCommit c;
           while ((c = rw.next()) != null) {
             Tag tag = tags.get(c);
-            if (tag != null && tag.refFlags.get(savedRef.flag)) {
+            if (tag != null && tag.refFlags.contains(savedRef.flag)) {
               m.lostRefs.add(new TagMatcher.LostRef(tag, savedRef.flag));
               err = true;
             }
@@ -184,7 +189,7 @@
           if (!err) {
             // All of the tags are still reachable. Update in-place.
             savedRef.compareAndSet(savedObjectId, currentRef.getObjectId());
-            m.mask.set(savedRef.flag);
+            m.mask.add(savedRef.flag);
           }
 
         } catch (IOException err) {
@@ -207,6 +212,7 @@
 
     try (TagWalk rw = new TagWalk(git)) {
       rw.setRetainBody(false);
+      RevFlag isTag = rw.newFlag("tag");
       for (Ref ref :
           git.getRefDatabase()
               .getRefsByPrefixWithExclusions(RefDatabase.ALL, SKIPPABLE_REF_PREFIXES)) {
@@ -216,9 +222,9 @@
         } else if (isTag(ref)) {
           // For a tag, remember where it points to.
           try {
-            addTag(rw, git.getRefDatabase().peel(ref));
+            addTag(rw, git.getRefDatabase().peel(ref), isTag);
           } catch (IOException e) {
-            addTag(rw, ref);
+            addTag(rw, ref, isTag);
           }
 
         } else {
@@ -227,17 +233,10 @@
         }
       }
 
-      // Traverse the complete history. Copy any flags from a commit to
-      // all of its ancestors. This automatically updates any Tag object
-      // as the TagCommit and the stored Tag object share the same
-      // underlying bit set.
+      // Traverse the complete history and propagate reachability to parents.
       TagCommit c;
       while ((c = (TagCommit) rw.next()) != null) {
-        BitSet mine = c.refFlags;
-        int pCnt = c.getParentCount();
-        for (int pIdx = 0; pIdx < pCnt; pIdx++) {
-          ((TagCommit) c.getParent(pIdx)).refFlags.or(mine);
-        }
+        c.propagateReachabilityToParents(isTag);
       }
     } catch (IOException e) {
       logger.atWarning().withCause(e).log("Error building tags for repository %s", projectName);
@@ -257,11 +256,16 @@
     proto
         .getTagList()
         .forEach(
-            t ->
-                tags.add(
-                    new Tag(
-                        idConverter.fromByteString(t.getId()),
-                        BitSet.valueOf(t.getFlags().asReadOnlyByteBuffer()))));
+            t -> {
+              RoaringBitmap flags = new RoaringBitmap();
+              ByteBuffer in = ByteBuffer.wrap(t.getFlags().toByteArray());
+              try {
+                flags.deserialize(in);
+              } catch (IOException e) {
+                logger.atSevere().withCause(e).log();
+              }
+              tags.add(new Tag(idConverter.fromByteString(t.getId()), flags));
+            });
     return new TagSet(Project.nameKey(proto.getProjectName()), refs, tags);
   }
 
@@ -277,12 +281,20 @@
                     .setFlag(cr.flag)
                     .build()));
     tags.forEach(
-        t ->
-            b.addTag(
-                TagProto.newBuilder()
-                    .setId(idConverter.toByteString(t))
-                    .setFlags(ByteString.copyFrom(t.refFlags.toByteArray()))
-                    .build()));
+        t -> {
+          t.refFlags.runOptimize();
+          ByteString.Output out = ByteString.newOutput(t.refFlags.serializedSizeInBytes());
+          try {
+            t.refFlags.serialize(new DataOutputStream(out));
+          } catch (IOException e) {
+            logger.atSevere().withCause(e).log();
+          }
+          b.addTag(
+              TagProto.newBuilder()
+                  .setId(idConverter.toByteString(t))
+                  .setFlags(out.toByteString())
+                  .build());
+        });
     return b.build();
   }
 
@@ -328,8 +340,8 @@
       refs.put(newRef.getName(), new CachedRef(newRef, newFlag));
 
       for (Tag tag : tags) {
-        if (tag.refFlags.get(srcFlag)) {
-          tag.refFlags.set(newFlag);
+        if (tag.refFlags.contains(srcFlag)) {
+          tag.refFlags.add(newFlag);
         }
       }
     }
@@ -341,34 +353,37 @@
     refs.putAll(old.refs);
 
     for (Tag srcTag : old.tags) {
-      BitSet mine = new BitSet();
-      mine.or(srcTag.refFlags);
-      tags.add(new Tag(srcTag, mine));
+      tags.add(new Tag(srcTag));
     }
 
     for (TagMatcher.LostRef lost : m.lostRefs) {
       Tag mine = tags.get(lost.tag);
       if (mine != null) {
-        mine.refFlags.clear(lost.flag);
+        mine.refFlags.remove(lost.flag);
       }
     }
   }
 
-  private void addTag(TagWalk rw, Ref ref) {
+  private void addTag(TagWalk rw, Ref ref, RevFlag isTag) {
     ObjectId id = ref.getPeeledObjectId();
     if (id == null) {
       id = ref.getObjectId();
     }
 
     if (!tags.contains(id)) {
-      BitSet flags;
+      RoaringBitmap flags;
       try {
-        flags = ((TagCommit) rw.parseCommit(id)).refFlags;
+        TagCommit commit = ((TagCommit) rw.parseCommit(id));
+        commit.add(isTag);
+        if (commit.refFlags == null) {
+          commit.refFlags = new RoaringBitmap();
+        }
+        flags = commit.refFlags;
       } catch (IncorrectObjectTypeException notCommit) {
-        flags = new BitSet();
+        flags = new RoaringBitmap();
       } catch (IOException e) {
         logger.atWarning().withCause(e).log("Error on %s of %s", ref.getName(), projectName);
-        flags = new BitSet();
+        flags = new RoaringBitmap();
       }
       tags.add(new Tag(id, flags));
     }
@@ -380,7 +395,10 @@
       rw.markStart(commit);
 
       int flag = refs.size();
-      commit.refFlags.set(flag);
+      if (commit.refFlags == null) {
+        commit.refFlags = new RoaringBitmap();
+      }
+      commit.refFlags.add(flag);
       refs.put(ref.getName(), new CachedRef(ref, flag));
     } catch (IncorrectObjectTypeException notCommit) {
       // No need to spam the logs.
@@ -414,16 +432,21 @@
   static final class Tag extends ObjectIdOwnerMap.Entry {
 
     // a RefCache.flag => isVisible map. This reference is aliased to the
-    // bitset in TagCommit.refFlags.
-    @VisibleForTesting final BitSet refFlags;
+    // RoaringBitmap in TagCommit.refFlags.
+    @VisibleForTesting final RoaringBitmap refFlags;
 
-    Tag(AnyObjectId id, BitSet flags) {
+    Tag(Tag src) {
+      this(src, src.refFlags.clone());
+    }
+
+    Tag(AnyObjectId id, RoaringBitmap flags) {
       super(id);
+      checkNotNull(flags);
       this.refFlags = flags;
     }
 
-    boolean has(BitSet mask) {
-      return refFlags.intersects(mask);
+    boolean has(RoaringBitmap mask) {
+      return RoaringBitmap.intersects(refFlags, mask);
     }
 
     @Override
@@ -432,7 +455,7 @@
     }
   }
 
-  /** A ref along with its index into BitSet. */
+  /** A ref along with its index into RoaringBitmap. */
   @VisibleForTesting
   static final class CachedRef extends AtomicReference<ObjectId> {
     private static final long serialVersionUID = 1L;
@@ -471,13 +494,50 @@
   }
 
   // TODO(hanwen): this would be better named as CommitWithReachability, as it also holds non-tags.
+  // However, non-tags will have a null refFlags field.
   private static final class TagCommit extends RevCommit {
     /** CachedRef.flag => isVisible, indicating if this commit is reachable from the ref. */
-    final BitSet refFlags;
+    RoaringBitmap refFlags;
 
     TagCommit(AnyObjectId id) {
       super(id);
-      refFlags = new BitSet();
+    }
+
+    /**
+     * Copy any flags from this commit to all of its ancestors.
+     *
+     * <p>Do not maintain a reference to the flags on non-tag commits after copying their flags to
+     * their ancestors. The flag copying automatically updates any Tag object as the TagCommit and
+     * the stored Tag object share the same underlying RoaringBitmap.
+     *
+     * @param isTag {@code RevFlag} indicating if this TagCommit is a tag
+     */
+    void propagateReachabilityToParents(RevFlag isTag) {
+      RoaringBitmap mine = refFlags;
+      if (mine != null) {
+        boolean canMoveBitmap = false;
+        if (!has(isTag)) {
+          refFlags = null;
+          canMoveBitmap = true;
+        }
+        int pCnt = getParentCount();
+        for (int pIdx = 0; pIdx < pCnt; pIdx++) {
+          TagCommit commit = (TagCommit) getParent(pIdx);
+          RoaringBitmap parentFlags = commit.refFlags;
+          if (parentFlags == null) {
+            if (canMoveBitmap) {
+              // This commit is not itself a Tag, so in order to reduce cloning overhead, migrate
+              // its refFlags object to its first parent with null refFlags
+              commit.refFlags = mine;
+              canMoveBitmap = false;
+            } else {
+              commit.refFlags = mine.clone();
+            }
+          } else {
+            parentFlags.or(mine);
+          }
+        }
+      }
     }
   }
 }
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 83643ec..4d3c375 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -34,7 +34,6 @@
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
-import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.DIRECT_PUSH;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.joining;
@@ -110,9 +109,9 @@
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CancellationMetrics;
 import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CreateGroupPermissionSyncer;
 import com.google.gerrit.server.DeadlineChecker;
+import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InvalidDeadlineException;
 import com.google.gerrit.server.PatchSetUtil;
@@ -120,6 +119,7 @@
 import com.google.gerrit.server.PublishCommentsOp;
 import com.google.gerrit.server.RequestInfo;
 import com.google.gerrit.server.RequestListener;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.cancellation.RequestCancelledException;
@@ -134,7 +134,6 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.ProjectConfigEntry;
-import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
 import com.google.gerrit.server.git.BanCommit;
@@ -161,7 +160,6 @@
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.patch.AutoMerger;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -260,9 +258,9 @@
  *
  * <p>Conceptually, most use of Gerrit is a push of some commits to refs/for/BRANCH. However, the
  * receive-pack protocol that this is based on allows multiple ref updates to be processed at once.
- * So we have to be prepared to also handle normal pushes (refs/heads/BRANCH), and legacy pushes
- * (refs/changes/CHANGE). It is hard to split this class up further, because normal pushes can also
- * result in updates to reviews, through the autoclose mechanism.
+ * So we have to be prepared to also handle normal pushes (refs/heads/BRANCH). It is hard to split
+ * this class up further, because normal pushes can also result in updates to reviews, through the
+ * autoclose mechanism.
  */
 class ReceiveCommits {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -272,6 +270,8 @@
       "Cannot delete project configuration from '" + RefNames.REFS_CONFIG + "'";
   private static final String INTERNAL_SERVER_ERROR = "internal server error";
 
+  public static final String DIRECT_PUSH_JUSTIFICATION_OPTION = "push-justification";
+
   interface Factory {
     ReceiveCommits create(
         ProjectState projectState,
@@ -371,8 +371,9 @@
   private final ChangeInserter.Factory changeInserterFactory;
   private final ChangeNotes.Factory notesFactory;
   private final ChangeReportFormatter changeFormatter;
+  private final ChangeUtil changeUtil;
   private final CmdLineParser.Factory optionParserFactory;
-  private final CommentsUtil commentsUtil;
+  private final DraftCommentsReader draftCommentsReader;
   private final PluginSetContext<CommentValidator> commentValidators;
   private final BranchCommitValidator.Factory commitValidatorFactory;
   private final Config config;
@@ -407,7 +408,6 @@
   private final ProjectConfig.Factory projectConfigFactory;
   private final SetPrivateOp.Factory setPrivateOpFactory;
   private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
-  private final DynamicItem<UrlFormatter> urlFormatter;
   private final AutoMerger autoMerger;
 
   // Assisted injected fields.
@@ -424,7 +424,6 @@
   private final Repository repo;
 
   // Collections populated during processing.
-  private final List<UpdateGroupsRequest> updateGroups;
   private final Queue<ValidationMessage> messages;
 
   /** Multimap of error text to refnames that produced that error. */
@@ -462,8 +461,9 @@
       ChangeInserter.Factory changeInserterFactory,
       ChangeNotes.Factory notesFactory,
       DynamicItem<ChangeReportFormatter> changeFormatterProvider,
+      ChangeUtil changeUtil,
       CmdLineParser.Factory optionParserFactory,
-      CommentsUtil commentsUtil,
+      DraftCommentsReader draftCommentsReader,
       BranchCommitValidator.Factory commitValidatorFactory,
       CreateGroupPermissionSyncer createGroupPermissionSyncer,
       CreateRefControl createRefControl,
@@ -497,7 +497,6 @@
       TagCache tagCache,
       SetPrivateOp.Factory setPrivateOpFactory,
       ReplyAttentionSetUpdates replyAttentionSetUpdates,
-      DynamicItem<UrlFormatter> urlFormatter,
       AutoMerger autoMerger,
       @Assisted ProjectState projectState,
       @Assisted IdentifiedUser user,
@@ -512,8 +511,9 @@
     this.batchUpdateFactory = batchUpdateFactory;
     this.cancellationMetrics = cancellationMetrics;
     this.changeFormatter = changeFormatterProvider.get();
+    this.changeUtil = changeUtil;
     this.changeInserterFactory = changeInserterFactory;
-    this.commentsUtil = commentsUtil;
+    this.draftCommentsReader = draftCommentsReader;
     this.commentValidators = commentValidators;
     this.commitValidatorFactory = commitValidatorFactory;
     this.config = config;
@@ -552,7 +552,6 @@
     this.projectConfigFactory = projectConfigFactory;
     this.setPrivateOpFactory = setPrivateOpFactory;
     this.replyAttentionSetUpdates = replyAttentionSetUpdates;
-    this.urlFormatter = urlFormatter;
     this.autoMerger = autoMerger;
 
     // Assisted injected fields.
@@ -574,7 +573,6 @@
     messages = new ConcurrentLinkedQueue<>();
     pushOptions = LinkedListMultimap.create();
     replaceByChange = new LinkedHashMap<>();
-    updateGroups = new ArrayList<>();
 
     used = false;
 
@@ -764,7 +762,9 @@
         String pushKind = magicBranch != null && magicBranch.submit ? "direct_submit" : "magic";
         metrics.pushCount.increment(pushKind, project.getName(), getUpdateType(magicCommands));
       }
-      try (RefUpdateContext ctx = RefUpdateContext.open(DIRECT_PUSH)) {
+      Optional<String> justification =
+          pushOptions.get(DIRECT_PUSH_JUSTIFICATION_OPTION).stream().findFirst();
+      try (RefUpdateContext ctx = RefUpdateContext.openDirectPush(justification)) {
         if (!regularCommands.isEmpty()) {
           metrics.pushCount.increment("direct", project.getName(), getUpdateType(regularCommands));
         }
@@ -1116,7 +1116,8 @@
               continue;
             }
             List<HumanComment> drafts =
-                commentsUtil.draftByChangeAuthor(changeNotes.get(), user.getAccountId());
+                draftCommentsReader.getDraftsByChangeAndDraftAuthor(
+                    changeNotes.get(), user.getAccountId());
             if (drafts.isEmpty()) {
               // If no comments, attention set shouldn't update since the user
               // didn't reply.
@@ -1133,9 +1134,6 @@
         create.addOps(bu);
       }
 
-      logger.atFine().log("Adding %d group update requests", newChanges.size());
-      updateGroups.forEach(r -> r.addOps(bu));
-
       logger.atFine().log("Executing batch");
       try {
         bu.execute();
@@ -2259,7 +2257,7 @@
 
       if (magicBranch != null && magicBranch.shouldPublishComments()) {
         List<HumanComment> drafts =
-            commentsUtil.draftByChangeAuthor(
+            draftCommentsReader.getDraftsByChangeAndDraftAuthor(
                 notesFactory.createChecked(change), user.getAccountId());
         ImmutableList<CommentForValidation> draftsForValidation =
             drafts.stream()
@@ -2298,7 +2296,7 @@
       } catch (IOException e) {
         throw new StorageException("Can't parse commit", e);
       }
-      List<String> idList = ChangeUtil.getChangeIdsFromFooter(create.commit, urlFormatter.get());
+      List<String> idList = changeUtil.getChangeIdsFromFooter(create.commit);
 
       if (idList.isEmpty()) {
         messages.add(
@@ -2368,27 +2366,12 @@
           boolean commitAlreadyTracked = !existingPatchSets.isEmpty();
           if (commitAlreadyTracked) {
             alreadyTracked++;
-            // Corner cases where an existing commit might need a new group:
-            // A) Existing commit has a null group; wasn't assigned during schema
-            //    upgrade, or schema upgrade is performed on a running server.
-            // B) Let A<-B<-C, then:
-            //      1. Push A to refs/heads/master
-            //      2. Push B to refs/for/master
-            //      3. Force push A~ to refs/heads/master
-            //      4. Push C to refs/for/master.
-            //      B will be in existing so we aren't replacing the patch set. It
-            //      used to have its own group, but now needs to to be changed to
-            //      A's group.
-            // C) Commit is a PatchSet of a pre-existing change uploaded with a
-            //    different target branch.
-            existingPatchSets.stream()
-                .forEach(i -> updateGroups.add(new UpdateGroupsRequest(i, c)));
             if (!(newChangeForAllNotInTarget || magicBranch.base != null)) {
               continue;
             }
           }
 
-          List<String> idList = ChangeUtil.getChangeIdsFromFooter(c, urlFormatter.get());
+          List<String> idList = changeUtil.getChangeIdsFromFooter(c);
           if (!idList.isEmpty()) {
             pending.put(c, lookupByChangeKey(c, Change.key(idList.get(idList.size() - 1).trim())));
           } else {
@@ -2563,9 +2546,6 @@
       for (ReplaceRequest replace : replaceByChange.values()) {
         replace.groups = ImmutableList.copyOf(groups.get(replace.newCommitId));
       }
-      for (UpdateGroupsRequest update : updateGroups) {
-        update.groups = ImmutableList.copyOf(groups.get(update.commit));
-      }
       logger.atFine().log("Finished updating groups from GroupCollector");
       return ImmutableList.copyOf(newChanges);
     }
@@ -3267,42 +3247,6 @@
     }
   }
 
-  private class UpdateGroupsRequest {
-    final PatchSet.Id psId;
-    final RevCommit commit;
-    List<String> groups = ImmutableList.of();
-
-    UpdateGroupsRequest(PatchSet.Id psId, RevCommit commit) {
-      this.psId = psId;
-      this.commit = commit;
-    }
-
-    private void addOps(BatchUpdate bu) {
-      bu.addOp(
-          psId.changeId(),
-          new BatchUpdateOp() {
-            @Override
-            public boolean updateChange(ChangeContext ctx) {
-              PatchSet ps = psUtil.get(ctx.getNotes(), psId);
-              List<String> oldGroups = ps.groups();
-              if (oldGroups == null) {
-                if (groups == null) {
-                  return false;
-                }
-              } else if (sameGroups(oldGroups, groups)) {
-                return false;
-              }
-              ctx.getUpdate(psId).setGroups(groups);
-              return true;
-            }
-          });
-    }
-
-    private boolean sameGroups(List<String> a, List<String> b) {
-      return Sets.newHashSet(a).equals(Sets.newHashSet(b));
-    }
-  }
-
   private class UpdateOneRefOp implements RepoOnlyOp {
     final ReceiveCommand cmd;
 
@@ -3553,8 +3497,7 @@
                         }
                       }
 
-                      for (String changeId :
-                          ChangeUtil.getChangeIdsFromFooter(c, urlFormatter.get())) {
+                      for (String changeId : changeUtil.getChangeIdsFromFooter(c)) {
                         if (changeDataByKey == null) {
                           changeDataByKey =
                               retryHelper
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index b4e8bc0..6444de5 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -40,7 +40,6 @@
 import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
@@ -61,11 +60,11 @@
 import com.google.gerrit.server.change.ReviewerModifier.ReviewerModificationList;
 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.git.validators.TopicValidator;
 import com.google.gerrit.server.mail.MailUtil.MailRecipients;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -134,7 +133,8 @@
   private final PatchSetUtil psUtil;
   private final ProjectCache projectCache;
   private final ReviewerModifier reviewerModifier;
-  private final DynamicItem<UrlFormatter> urlFormatter;
+  private final ChangeUtil changeUtil;
+  private final TopicValidator topicValidator;
 
   private final ProjectState projectState;
   private final Change change;
@@ -179,7 +179,8 @@
       ProjectCache projectCache,
       EmailNewPatchSet.Factory emailNewPatchSetFactory,
       ReviewerModifier reviewerModifier,
-      DynamicItem<UrlFormatter> urlFormatter,
+      ChangeUtil changeUtil,
+      TopicValidator topicValidator,
       @Assisted ProjectState projectState,
       @Assisted Change change,
       @Assisted boolean checkMergedInto,
@@ -207,7 +208,8 @@
     this.projectCache = projectCache;
     this.emailNewPatchSetFactory = emailNewPatchSetFactory;
     this.reviewerModifier = reviewerModifier;
-    this.urlFormatter = urlFormatter;
+    this.changeUtil = changeUtil;
+    this.topicValidator = topicValidator;
 
     this.projectState = projectState;
     this.change = change;
@@ -290,7 +292,7 @@
       }
       if (magicBranch.topic != null && !magicBranch.topic.equals(ctx.getChange().getTopic())) {
         try {
-          update.setTopic(magicBranch.topic);
+          update.setTopic(magicBranch.topic, topicValidator);
         } catch (ValidationException ex) {
           throw new BadRequestException(ex.getMessage());
         }
@@ -374,20 +376,20 @@
     // bulk new change email.
     Stream<ReviewerInput> inputs =
         Streams.concat(
-            Streams.stream(
-                newReviewerInputFromCommitIdentity(
-                    change,
-                    psInfo.getCommitId(),
-                    psInfo.getAuthor().getAccount(),
-                    NotifyHandling.NONE,
-                    newPatchSet.uploader())),
-            Streams.stream(
-                newReviewerInputFromCommitIdentity(
-                    change,
-                    psInfo.getCommitId(),
-                    psInfo.getCommitter().getAccount(),
-                    NotifyHandling.NONE,
-                    newPatchSet.uploader())));
+            newReviewerInputFromCommitIdentity(
+                change,
+                psInfo.getCommitId(),
+                psInfo.getAuthor().getAccount(),
+                NotifyHandling.NONE,
+                newPatchSet.uploader())
+                .stream(),
+            newReviewerInputFromCommitIdentity(
+                change,
+                psInfo.getCommitId(),
+                psInfo.getCommitter().getAccount(),
+                NotifyHandling.NONE,
+                newPatchSet.uploader())
+                .stream());
     if (magicBranch != null) {
       inputs =
           Streams.concat(
@@ -499,7 +501,7 @@
     change.setStatus(Change.Status.NEW);
     change.setCurrentPatchSet(info);
 
-    List<String> idList = ChangeUtil.getChangeIdsFromFooter(commit, urlFormatter.get());
+    List<String> idList = changeUtil.getChangeIdsFromFooter(commit);
     change.setKey(Change.key(idList.get(idList.size() - 1).trim()));
   }
 
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index c29dd1a..6adaae2 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.extensions.registration.DynamicItem;
@@ -38,6 +39,7 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
@@ -49,7 +51,10 @@
 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.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.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
@@ -69,10 +74,10 @@
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import java.util.regex.Pattern;
-import org.eclipse.jgit.diff.DiffEntry;
-import org.eclipse.jgit.diff.DiffFormatter;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -83,7 +88,6 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.SystemReader;
-import org.eclipse.jgit.util.io.DisabledOutputStream;
 
 /**
  * Represents a list of {@link CommitValidationListener}s to run for a push to one branch of one
@@ -105,10 +109,12 @@
     private final AllProjectsName allProjects;
     private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker;
     private final AccountValidator accountValidator;
+    private final AccountCache accountCache;
     private final ProjectCache projectCache;
     private final ProjectConfig.Factory projectConfigFactory;
     private final DiffOperations diffOperations;
     private final Config config;
+    private final ChangeUtil changeUtil;
 
     @Inject
     Factory(
@@ -121,9 +127,11 @@
         AllProjectsName allProjects,
         ExternalIdsConsistencyChecker externalIdsConsistencyChecker,
         AccountValidator accountValidator,
+        AccountCache accountCache,
         ProjectCache projectCache,
         ProjectConfig.Factory projectConfigFactory,
-        DiffOperations diffOperations) {
+        DiffOperations diffOperations,
+        ChangeUtil changeUtil) {
       this.gerritIdent = gerritIdent;
       this.urlFormatter = urlFormatter;
       this.config = config;
@@ -133,9 +141,11 @@
       this.allProjects = allProjects;
       this.externalIdsConsistencyChecker = externalIdsConsistencyChecker;
       this.accountValidator = accountValidator;
+      this.accountCache = accountCache;
       this.projectCache = projectCache;
       this.projectConfigFactory = projectConfigFactory;
       this.diffOperations = diffOperations;
+      this.changeUtil = changeUtil;
     }
 
     public CommitValidators forReceiveCommits(
@@ -156,16 +166,16 @@
           .add(new ProjectStateValidationListener(projectState))
           .add(new AmendedGerritMergeCommitValidationListener(perm, gerritIdent))
           .add(new AuthorUploaderValidator(user, perm, urlFormatter.get()))
-          .add(new FileCountValidator(repoManager, config))
+          .add(new FileCountValidator(config, urlFormatter.get(), diffOperations))
           .add(new CommitterUploaderValidator(user, perm, urlFormatter.get()))
           .add(new SignedOffByValidator(user, perm, projectState))
           .add(
               new ChangeIdValidator(
-                  projectState, user, urlFormatter.get(), config, sshInfo, change))
+                  changeUtil, projectState, user, urlFormatter.get(), config, sshInfo, change))
           .add(new ConfigValidator(projectConfigFactory, branch, user, rw, allUsers, allProjects))
           .add(new BannedCommitsValidator(rejectCommits))
           .add(new PluginCommitValidationListener(pluginValidators, skipValidation))
-          .add(new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker))
+          .add(new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker, accountCache))
           .add(new AccountCommitValidator(repoManager, allUsers, accountValidator))
           .add(new GroupCommitValidator(allUsers))
           .add(new LabelConfigValidator(diffOperations));
@@ -188,14 +198,14 @@
           .add(new ProjectStateValidationListener(projectState))
           .add(new AmendedGerritMergeCommitValidationListener(perm, gerritIdent))
           .add(new AuthorUploaderValidator(user, perm, urlFormatter.get()))
-          .add(new FileCountValidator(repoManager, config))
+          .add(new FileCountValidator(config, urlFormatter.get(), diffOperations))
           .add(new SignedOffByValidator(user, perm, projectState))
           .add(
               new ChangeIdValidator(
-                  projectState, user, urlFormatter.get(), config, sshInfo, change))
+                  changeUtil, projectState, user, urlFormatter.get(), config, sshInfo, change))
           .add(new ConfigValidator(projectConfigFactory, branch, user, rw, allUsers, allProjects))
           .add(new PluginCommitValidationListener(pluginValidators))
-          .add(new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker))
+          .add(new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker, accountCache))
           .add(new AccountCommitValidator(repoManager, allUsers, accountValidator))
           .add(new GroupCommitValidator(allUsers))
           .add(new LabelConfigValidator(diffOperations));
@@ -280,6 +290,7 @@
 
     private static final Pattern CHANGE_ID = Pattern.compile(CHANGE_ID_PATTERN);
 
+    private final ChangeUtil changeUtil;
     private final ProjectState projectState;
     private final UrlFormatter urlFormatter;
     private final String installCommitMsgHookCommand;
@@ -288,12 +299,14 @@
     private final Change change;
 
     public ChangeIdValidator(
+        ChangeUtil changeUtil,
         ProjectState projectState,
         IdentifiedUser user,
         UrlFormatter urlFormatter,
         Config config,
         SshInfo sshInfo,
         Change change) {
+      this.changeUtil = changeUtil;
       this.projectState = projectState;
       this.user = user;
       this.urlFormatter = urlFormatter;
@@ -310,7 +323,7 @@
       }
       RevCommit commit = receiveEvent.commit;
       List<CommitValidationMessage> messages = new ArrayList<>();
-      List<String> idList = ChangeUtil.getChangeIdsFromFooter(commit, urlFormatter);
+      List<String> idList = changeUtil.getChangeIdsFromFooter(commit);
 
       if (idList.isEmpty()) {
         String shortMsg = commit.getShortMessage();
@@ -427,11 +440,15 @@
   /** Limits the number of files per change. */
   private static class FileCountValidator implements CommitValidationListener {
 
-    private final GitRepositoryManager repoManager;
-    private final int maxFileCount;
+    private static final int FILE_COUNT_WARNING_THRESHOLD = 10_000;
 
-    FileCountValidator(GitRepositoryManager repoManager, Config config) {
-      this.repoManager = repoManager;
+    private final int maxFileCount;
+    private final UrlFormatter urlFormatter;
+    private final DiffOperations diffOperations;
+
+    FileCountValidator(Config config, UrlFormatter urlFormatter, DiffOperations diffOperations) {
+      this.urlFormatter = urlFormatter;
+      this.diffOperations = diffOperations;
       maxFileCount = config.getInt("change", null, "maxFiles", 100_000);
     }
 
@@ -459,7 +476,14 @@
                   "Exceeding maximum number of files per change (%d > %d)",
                   changedFiles, maxFileCount));
         }
-      } catch (IOException e) {
+        if (changedFiles > FILE_COUNT_WARNING_THRESHOLD) {
+          String host = getGerritHost(urlFormatter.getWebUrl().orElse(null));
+          String project = receiveEvent.project.getNameKey().get();
+          logger.atWarning().log(
+              "Warning: Change with %d files on host %s, project %s, ref %s",
+              changedFiles, host, project, refName);
+        }
+      } catch (DiffNotAvailableException e) {
         // This happens e.g. for cherrypicks.
         if (!receiveEvent.command.getRefName().startsWith(REFS_CHANGES)) {
           logger.atWarning().withCause(e).log(
@@ -469,20 +493,18 @@
       return Collections.emptyList();
     }
 
-    private long countChangedFiles(CommitReceivedEvent receiveEvent) throws IOException {
-      try (Repository repository = repoManager.openRepository(receiveEvent.project.getNameKey());
-          DiffFormatter diffFormatter = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
-        diffFormatter.setRepository(repository);
-        // Do not detect renames; that would require reading file contents, which is slow for large
-        // files.
-        diffFormatter.setDetectRenames(false);
-        // For merge commits, i.e. >1 parents, we use parent #0 by convention.
-        List<DiffEntry> diffEntries =
-            diffFormatter.scan(
-                receiveEvent.commit.getParentCount() > 0 ? receiveEvent.commit.getParent(0) : null,
-                receiveEvent.commit);
-        return diffEntries.stream().map(DiffEntry::getNewPath).distinct().count();
-      }
+    private int countChangedFiles(CommitReceivedEvent receiveEvent)
+        throws DiffNotAvailableException {
+      // For merge commits this will compare against auto-merge.
+      Map<String, FileDiffOutput> modifiedFiles =
+          diffOperations.listModifiedFilesAgainstParent(
+              receiveEvent.getProjectNameKey(), receiveEvent.commit, 0, DiffOptions.DEFAULTS);
+      // We don't want to count the COMMIT_MSG and MERGE_LIST files.
+      List<FileDiffOutput> modifiedFilesList =
+          modifiedFiles.values().stream()
+              .filter(p -> !Patch.isMagic(p.newPath().orElse("")))
+              .collect(Collectors.toList());
+      return modifiedFilesList.size();
     }
   }
 
@@ -811,12 +833,16 @@
   /** Validates updates to refs/meta/external-ids. */
   public static class ExternalIdUpdateListener implements CommitValidationListener {
     private final AllUsersName allUsers;
+    private final AccountCache accountCache;
     private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker;
 
     public ExternalIdUpdateListener(
-        AllUsersName allUsers, ExternalIdsConsistencyChecker externalIdsConsistencyChecker) {
+        AllUsersName allUsers,
+        ExternalIdsConsistencyChecker externalIdsConsistencyChecker,
+        AccountCache accountCache) {
       this.externalIdsConsistencyChecker = externalIdsConsistencyChecker;
       this.allUsers = allUsers;
+      this.accountCache = accountCache;
     }
 
     @Override
@@ -826,7 +852,7 @@
           && RefNames.REFS_EXTERNAL_IDS.equals(receiveEvent.refName)) {
         try {
           List<ConsistencyProblemInfo> problems =
-              externalIdsConsistencyChecker.check(receiveEvent.commit);
+              externalIdsConsistencyChecker.check(accountCache, receiveEvent.commit);
           List<CommitValidationMessage> msgs =
               problems.stream()
                   .map(
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidators.java b/java/com/google/gerrit/server/git/validators/MergeValidators.java
index 811e960..514dee1 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -373,8 +373,7 @@
       // might get corrupted. Thus don't allow merges into All-Users group refs
       // which updates group files (i.e., group.config, members and subgroups).
       // But it is still useful to allow users to update files apart from group
-      // files. For example, users can maintain task config in group refs which
-      // allows users to collaborate and review changes on group specific task configs.
+      // files. For example, users can upload named destinations into group refs.
       ChangeData cd =
           changeDataFactory.create(destProject.getProject().getNameKey(), patchSetId.changeId());
       try {
diff --git a/java/com/google/gerrit/server/git/validators/TopicValidator.java b/java/com/google/gerrit/server/git/validators/TopicValidator.java
new file mode 100644
index 0000000..46c56f3
--- /dev/null
+++ b/java/com/google/gerrit/server/git/validators/TopicValidator.java
@@ -0,0 +1,55 @@
+// 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.git.validators;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import org.eclipse.jgit.lib.Config;
+
+/** Validator for topic changes. */
+@Singleton
+public class TopicValidator {
+
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final int topicLimit;
+
+  @Inject
+  TopicValidator(
+      @GerritServerConfig Config serverConfig, Provider<InternalChangeQuery> queryProvider) {
+    this.queryProvider = queryProvider;
+    int configuredLimit = serverConfig.getInt("change", "topicLimit", 5_000);
+    this.topicLimit = configuredLimit > 0 ? configuredLimit : Integer.MAX_VALUE;
+  }
+
+  public void validateSize(@Nullable String topic) throws ValidationException {
+    if (Strings.isNullOrEmpty(topic)) {
+      return;
+    }
+    int topicSize = queryProvider.get().noFields().byTopicOpen(topic).size();
+    if (topicSize >= topicLimit) {
+      throw new ValidationException(
+          String.format(
+              "Topic '%s' already contains maximum number of allowed changes per 'topicLimit'"
+                  + " server config value %d.",
+              topic, topicLimit));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/group/db/AuditLogReader.java b/java/com/google/gerrit/server/group/db/AuditLogReader.java
index 8da2f50..ae2b2fc 100644
--- a/java/com/google/gerrit/server/group/db/AuditLogReader.java
+++ b/java/com/google/gerrit/server/group/db/AuditLogReader.java
@@ -36,6 +36,7 @@
 import java.util.List;
 import java.util.Optional;
 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;
@@ -53,10 +54,14 @@
   private final AllUsersName allUsersName;
   private final NoteDbUtil noteDbUtil;
 
+  private final boolean ignoreRecordsFromUnidentifiedUsers;
+
   @Inject
-  public AuditLogReader(AllUsersName allUsersName, NoteDbUtil noteDbUtil) {
+  public AuditLogReader(AllUsersName allUsersName, NoteDbUtil noteDbUtil, Config cfg) {
     this.allUsersName = allUsersName;
     this.noteDbUtil = noteDbUtil;
+    ignoreRecordsFromUnidentifiedUsers =
+        cfg.getBoolean("groups", "auditLog", "ignoreRecordsFromUnidentifiedUsers", false);
   }
 
   // Having separate methods for reading the two types of audit records mirrors the split in
@@ -144,9 +149,12 @@
   private Optional<ParsedCommit> parse(AccountGroup.UUID uuid, RevCommit c) {
     Optional<Account.Id> authorId = noteDbUtil.parseIdent(c.getAuthorIdent());
     if (!authorId.isPresent()) {
-      // Only report audit events from identified users, since this was a non-nullable field in
-      // ReviewDb. May be revisited.
-      return Optional.empty();
+      if (ignoreRecordsFromUnidentifiedUsers) {
+        // Only report audit events from identified users, since this was a non-nullable field in
+        // ReviewDb.
+        return Optional.empty();
+      }
+      authorId = Optional.of(Account.UNKNOWN_ACCOUNT_ID);
     }
 
     List<Account.Id> addedMembers = new ArrayList<>();
diff --git a/java/com/google/gerrit/server/group/db/GroupConfigCommitMessage.java b/java/com/google/gerrit/server/group/db/GroupConfigCommitMessage.java
index 77c284a..6e2f02f 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfigCommitMessage.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfigCommitMessage.java
@@ -57,7 +57,7 @@
     StringJoiner footerJoiner = new StringJoiner("\n", "\n\n", "");
     footerJoiner.setEmptyValue("");
     Streams.concat(
-            Streams.stream(getFooterForRename()),
+            getFooterForRename().stream(),
             getFootersForMemberModifications(),
             getFootersForSubgroupModifications())
         .sorted()
diff --git a/java/com/google/gerrit/server/index/AbstractIndexModule.java b/java/com/google/gerrit/server/index/AbstractIndexModule.java
index 81c517f..9e9da91 100644
--- a/java/com/google/gerrit/server/index/AbstractIndexModule.java
+++ b/java/com/google/gerrit/server/index/AbstractIndexModule.java
@@ -32,6 +32,7 @@
  * Base class to establish implementation-independent index bindings. To be subclassed by concrete
  * index implementations, such as {@link com.google.gerrit.lucene.LuceneIndexModule}.
  */
+@SuppressWarnings("ProvidesMethodOutsideOfModule")
 public abstract class AbstractIndexModule extends AbstractModule {
   public static final String INDEX_MODULE = "index-module";
 
diff --git a/java/com/google/gerrit/server/index/IndexModule.java b/java/com/google/gerrit/server/index/IndexModule.java
index a6aeb6b..c0bd62f 100644
--- a/java/com/google/gerrit/server/index/IndexModule.java
+++ b/java/com/google/gerrit/server/index/IndexModule.java
@@ -75,6 +75,7 @@
  * <p>This module should not be used directly except by specific secondary indexer implementations
  * (e.g. Lucene).
  */
+@SuppressWarnings("ProvidesMethodOutsideOfModule")
 public class IndexModule extends LifecycleModule {
   public static final ImmutableCollection<SchemaDefinitions<?>> ALL_SCHEMA_DEFS =
       ImmutableList.of(
@@ -171,7 +172,7 @@
       return ImmutableList.of(groups);
     }
 
-    Collection<IndexDefinition<?, ?, ?>> result =
+    ImmutableList<IndexDefinition<?, ?, ?>> result =
         ImmutableList.of(accounts, groups, changes, projects);
     Set<String> expected =
         FluentIterable.from(ALL_SCHEMA_DEFS).transform(SchemaDefinitions::getName).toSet();
diff --git a/java/com/google/gerrit/server/index/account/AccountField.java b/java/com/google/gerrit/server/index/account/AccountField.java
index e578b4f..abff3e9 100644
--- a/java/com/google/gerrit/server/index/account/AccountField.java
+++ b/java/com/google/gerrit/server/index/account/AccountField.java
@@ -14,13 +14,16 @@
 
 package com.google.gerrit.server.index.account;
 
+import static com.google.common.base.Preconditions.checkState;
 import static java.util.stream.Collectors.toSet;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.index.IndexedField;
 import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
@@ -238,12 +241,22 @@
               a ->
                   a.externalIds().stream()
                       .filter(e -> e.blobId() != null)
-                      .map(ExternalId::toByteArray)
+                      .map(AccountField::serializeExternalId)
                       .collect(toSet()));
 
   public static final IndexedField<AccountState, Iterable<byte[]>>.SearchSpec
       EXTERNAL_ID_STATE_SPEC = EXTERNAL_ID_STATE_FIELD.storedOnly("external_id_state");
 
+  @VisibleForTesting
+  public static byte[] serializeExternalId(ExternalId extId) {
+    checkState(extId.blobId() != null, "Missing blobId in external ID %s", extId.key().get());
+    byte[] b = new byte[2 * ObjectIds.STR_LEN + 1];
+    extId.key().sha1().copyTo(b, 0);
+    b[ObjectIds.STR_LEN] = ':';
+    extId.blobId().copyTo(b, ObjectIds.STR_LEN + 1);
+    return b;
+  }
+
   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/AccountSchemaDefinitions.java b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
index 8e7d964..fd264a1 100644
--- a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
@@ -85,7 +85,10 @@
           .build();
 
   // Upgrade Lucene to 7.x requires reindexing.
-  static final Schema<AccountState> V12 = schema(V11);
+  @Deprecated static final Schema<AccountState> V12 = schema(V11);
+
+  // Upgrade Lucene to 8.x requires reindexing.
+  static final Schema<AccountState> V13 = schema(V12);
 
   /**
    * Name of the account index to be used when contacting index backends or loading configurations.
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index 4f411a2..3935108 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -22,7 +22,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Stopwatch;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSortedSet;
+import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
@@ -31,6 +31,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.index.SiteIndexer;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.MultiProgressMonitor.Task;
@@ -46,10 +47,13 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ProgressMonitor;
 import org.eclipse.jgit.lib.Repository;
@@ -82,6 +86,7 @@
   private final ChangeIndexer.Factory indexerFactory;
   private final ChangeNotes.Factory notesFactory;
   private final ProjectCache projectCache;
+  private final Set<Project.NameKey> projectsToSkip;
 
   @Inject
   AllChangesIndexer(
@@ -91,7 +96,8 @@
       @IndexExecutor(BATCH) ListeningExecutorService executor,
       ChangeIndexer.Factory indexerFactory,
       ChangeNotes.Factory notesFactory,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      @GerritServerConfig Config config) {
     this.multiProgressMonitorFactory = multiProgressMonitorFactory;
     this.changeDataFactory = changeDataFactory;
     this.repoManager = repoManager;
@@ -99,6 +105,11 @@
     this.indexerFactory = indexerFactory;
     this.notesFactory = notesFactory;
     this.projectCache = projectCache;
+    this.projectsToSkip =
+        Sets.newHashSet(config.getStringList("index", null, "excludeProjectFromChangeReindex"))
+            .stream()
+            .map(p -> Project.NameKey.parse(p))
+            .collect(Collectors.toSet());
   }
 
   @AutoValue
@@ -225,19 +236,32 @@
 
     @Override
     public Void call() throws Exception {
-      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.metaIdByChange(),
-              projectSlice.name(),
-              id -> (id.get() % projectSlice.slices()) == projectSlice.slice())
-          .forEach(r -> index(r));
-      OnlineReindexMode.end();
+      String oldThreadName = Thread.currentThread().getName();
+      try {
+        Thread.currentThread()
+            .setName(
+                oldThreadName
+                    + "["
+                    + projectSlice.name().toString()
+                    + "-"
+                    + projectSlice.slice()
+                    + "]");
+        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.metaIdByChange(),
+                projectSlice.name(),
+                id -> (id.get() % projectSlice.slices()) == projectSlice.slice())
+            .forEach(r -> index(r));
+        OnlineReindexMode.end();
+      } finally {
+        Thread.currentThread().setName(oldThreadName);
+      }
       return null;
     }
 
@@ -302,7 +326,7 @@
     }
 
     private List<ListenableFuture<?>> schedule() throws ProjectsCollectionFailure {
-      ImmutableSortedSet<Project.NameKey> projects = projectCache.all();
+      Set<Project.NameKey> projects = Sets.difference(projectCache.all(), projectsToSkip);
       int projectCount = projects.size();
       slicingProjects = mpm.beginSubTask("Slicing projects", projectCount);
       for (Project.NameKey name : projects) {
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 383b6d6..54c864e 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -24,6 +23,7 @@
 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.Splitter;
 import com.google.common.collect.HashBasedTable;
@@ -36,6 +36,7 @@
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.Files;
+import com.google.common.primitives.Ints;
 import com.google.common.primitives.Longs;
 import com.google.common.reflect.TypeToken;
 import com.google.gerrit.common.Nullable;
@@ -63,7 +64,6 @@
 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.index.change.StalenessChecker.RefStatePattern;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -80,6 +80,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
@@ -801,7 +802,7 @@
   public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec LABEL_SPEC =
       LABEL_FIELD.exact("label2");
 
-  private static Iterable<String> getLabels(ChangeData cd) {
+  private static Set<String> getLabels(ChangeData cd) {
     Set<String> allApprovals = new HashSet<>();
     Set<String> distinctApprovals = new HashSet<>();
     Table<String, Short, Integer> voteCounts = HashBasedTable.create();
@@ -1269,21 +1270,19 @@
   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; */
+  /** Star labels on this change in the format: &lt;account-id&gt; */
   public static final IndexedField<ChangeData, Iterable<String>> STAR_FIELD =
       IndexedField.<ChangeData>iterableStringBuilder("Star")
           .stored()
           .build(
               cd ->
                   Iterables.transform(
-                      cd.stars().entries(),
-                      e ->
-                          StarredChangesUtil.StarField.create(e.getKey(), e.getValue()).toString()),
+                      cd.stars(), accountId -> StarField.create(accountId).toString()),
               (cd, field) ->
                   cd.setStars(
                       StreamSupport.stream(field.spliterator(), false)
-                          .map(f -> StarredChangesUtil.StarField.parse(f))
-                          .collect(toImmutableListMultimap(e -> e.accountId(), e -> e.label()))));
+                          .map(f -> StarField.parse(f).accountId())
+                          .collect(toImmutableList())));
 
   public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec STAR_SPEC =
       STAR_FIELD.exact(ChangeQueryBuilder.FIELD_STAR);
@@ -1291,7 +1290,7 @@
   /** Users that have starred the change with any label. */
   public static final IndexedField<ChangeData, Iterable<Integer>> STARBY_FIELD =
       IndexedField.<ChangeData>iterableIntegerBuilder("StarBy")
-          .build(cd -> Iterables.transform(cd.stars().keySet(), Account.Id::get));
+          .build(cd -> Iterables.transform(cd.stars(), Account.Id::get));
 
   public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec STARBY_SPEC =
       STARBY_FIELD.integer(ChangeQueryBuilder.FIELD_STARBY);
@@ -1541,7 +1540,7 @@
     return Lists.transform(records, r -> GSON.toJson(new StoredSubmitRecord(r)).getBytes(UTF_8));
   }
 
-  private static Iterable<byte[]> storedSubmitRecords(ChangeData cd, SubmitRuleOptions opts) {
+  private static List<byte[]> storedSubmitRecords(ChangeData cd, SubmitRuleOptions opts) {
     return storedSubmitRecords(cd.submitRecords(opts));
   }
 
@@ -1706,6 +1705,30 @@
   public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec REF_STATE_PATTERN_SPEC =
       REF_STATE_PATTERN_FIELD.storedOnly("ref_state_pattern");
 
+  public static final IndexedField<ChangeData, Iterable<String>> CUSTOM_KEYED_VALUES_FIELD =
+      IndexedField.<ChangeData>iterableStringBuilder("CustomKeyedValues")
+          .stored()
+          .build(
+              cd ->
+                  cd.customKeyedValues().entrySet().stream()
+                      .map(e -> e.getKey() + "=" + e.getValue())
+                      .collect(toList()),
+              (cd, field) -> {
+                Map<String, String> ckv = new HashMap<>();
+                for (String entry : field) {
+                  int splitPoint = entry.indexOf('=');
+                  if (splitPoint < 0) {
+                    continue;
+                  }
+                  ckv.put(entry.substring(0, splitPoint), entry.substring(splitPoint + 1));
+                }
+                cd.setCustomKeyedValues(ckv);
+              });
+
+  public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec
+      CUSTOM_KEYED_VALUES_SPEC =
+          CUSTOM_KEYED_VALUES_FIELD.prefix(ChangeQueryBuilder.FIELD_CUSTOM_KEYED_VALUES);
+
   @Nullable
   private static String getTopic(ChangeData cd) {
     Change c = cd.change();
@@ -1784,4 +1807,45 @@
     }
     return str;
   }
+
+  @AutoValue
+  abstract static class StarField {
+    private static final String SEPARATOR = ":";
+
+    @Nullable
+    static StarField parse(String s) {
+      Integer id;
+      int p = s.indexOf(SEPARATOR);
+      if (p >= 0) {
+        id = Ints.tryParse(s.substring(0, p));
+      } else {
+        // NOTE: This code branch should not be removed. This code is used internally by Google and
+        // must not be changed without approval from a Google contributor. In
+        // 992877d06d3492f78a3b189eb5579ddb86b9f0da we accidentally changed index writing to write
+        // <account_id> instead of <account_id>:star. As some servers have picked that up and wrote
+        // index entries with the short format, we should keep support its parsing.
+        id = Ints.tryParse(s);
+      }
+      if (id == null) {
+        return null;
+      }
+      return create(Account.id(id));
+    }
+
+    static StarField create(Account.Id accountId) {
+      return new AutoValue_ChangeField_StarField(accountId);
+    }
+
+    public abstract Account.Id accountId();
+
+    @Override
+    public final String toString() {
+      // NOTE: The ":star" addition is used internally by Google and must not be removed without
+      // approval from a Google contributor. This method is used for writing change index data.
+      // Historically, we supported different kinds of labels, which were stored in this
+      // format, with "star" being the only label in use. This label addition stayed in order to
+      // keep the index format consistent while removing the star-label support.
+      return accountId() + SEPARATOR + "star";
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index 517809a..faa5629 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -27,6 +27,8 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.index.Index;
+import com.google.gerrit.metrics.proc.ThreadMXBeanFactory;
+import com.google.gerrit.metrics.proc.ThreadMXBeanInterface;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.IndexExecutor;
 import com.google.gerrit.server.index.StalenessCheckResult;
@@ -62,6 +64,7 @@
  */
 public class ChangeIndexer {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final ThreadMXBeanInterface threadMxBean = ThreadMXBeanFactory.create();
 
   public interface Factory {
     ChangeIndexer create(ListeningExecutorService executor, ChangeIndex index);
@@ -218,27 +221,40 @@
   }
 
   private void indexImpl(ChangeData cd) {
-    logger.atFine().log("Reindex change %d in index.", cd.getId().get());
-    for (Index<?, ChangeData> i : getWriteIndexes()) {
-      try (TraceTimer traceTimer =
-          TraceContext.newTimer(
-              "Reindexing change in index",
-              Metadata.builder()
-                  .changeId(cd.getId().get())
-                  .patchSetId(cd.currentPatchSet().number())
-                  .indexVersion(i.getSchema().getVersion())
-                  .build())) {
-        if (isFirstInsertForEntry.equals(IsFirstInsertForEntry.YES)) {
-          i.insert(cd);
-        } else {
-          i.replace(cd);
+    long memoryAtStart = 0;
+    if (logger.atFine().isEnabled()) {
+      memoryAtStart = threadMxBean.getCurrentThreadAllocatedBytes();
+      logger.atFine().log("Reindex change %d in index.", cd.getId().get());
+    }
+    try {
+      for (Index<?, ChangeData> i : getWriteIndexes()) {
+        try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                "Reindexing change in index",
+                Metadata.builder()
+                    .changeId(cd.getId().get())
+                    .patchSetId(cd.currentPatchSet().number())
+                    .indexVersion(i.getSchema().getVersion())
+                    .build())) {
+          if (isFirstInsertForEntry.equals(IsFirstInsertForEntry.YES)) {
+            i.insert(cd);
+          } else {
+            i.replace(cd);
+          }
+        } catch (RuntimeException e) {
+          throw new StorageException(
+              String.format(
+                  "Failed to reindex change %d in index version %d (current patch set = %d)",
+                  cd.getId().get(), i.getSchema().getVersion(), cd.currentPatchSet().number()),
+              e);
         }
-      } catch (RuntimeException e) {
-        throw new StorageException(
-            String.format(
-                "Failed to reindex change %d in index version %d (current patch set = %d)",
-                cd.getId().get(), i.getSchema().getVersion(), cd.currentPatchSet().number()),
-            e);
+      }
+    } finally {
+      if (logger.atFine().isEnabled()) {
+        long memAllocated = threadMxBean.getCurrentThreadAllocatedBytes() - memoryAtStart;
+        logger.atFine().log(
+            "Reindexing of change %d allocated %d bytes of memory.",
+            cd.getId().get(), memAllocated);
       }
     }
     fireChangeIndexedEvent(cd.project().get(), cd.getId().get());
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index e74ce8f..5474e6b 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -239,7 +239,7 @@
           .build();
 
   /** Remove assignee field. */
-  @SuppressWarnings("deprecation")
+  @Deprecated
   static final Schema<ChangeData> V82 =
       new Schema.Builder<ChangeData>()
           .add(V81)
@@ -247,6 +247,16 @@
           .remove(ChangeField.ASSIGNEE_FIELD)
           .build();
 
+  /** Upgrade Lucene to 8.x requires reindexing. */
+  @Deprecated static final Schema<ChangeData> V83 = schema(V82);
+
+  static final Schema<ChangeData> V84 =
+      new Schema.Builder<ChangeData>()
+          .add(V83)
+          .addIndexedFields(ChangeField.CUSTOM_KEYED_VALUES_FIELD)
+          .addSearchSpecs(ChangeField.CUSTOM_KEYED_VALUES_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 8f5e36e..00642a9 100644
--- a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
+++ b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
@@ -21,7 +21,6 @@
 
 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.gerrit.entities.Change;
 import com.google.gerrit.index.IndexConfig;
@@ -52,10 +51,6 @@
  */
 public class IndexedChangeQuery extends IndexedQuery<Change.Id, ChangeData>
     implements ChangeDataSource, Matchable<ChangeData> {
-  public static QueryOptions oneResult() {
-    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) {
@@ -69,6 +64,24 @@
       int pageSizeMultiplier,
       int limit,
       Set<String> fields) {
+    return createOptions(
+        config,
+        start,
+        pageSize,
+        pageSizeMultiplier,
+        limit,
+        /* allowIncompleteResults= */ false,
+        fields);
+  }
+
+  public static QueryOptions createOptions(
+      IndexConfig config,
+      int start,
+      int pageSize,
+      int pageSizeMultiplier,
+      int limit,
+      boolean allowIncompleteResults,
+      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())
@@ -77,7 +90,8 @@
       fields.add(PROJECT_SPEC.getName());
       fields.add(NUMERIC_ID_STR_SPEC.getName());
     }
-    return QueryOptions.create(config, start, pageSize, pageSizeMultiplier, limit, fields);
+    return QueryOptions.create(
+        config, start, pageSize, pageSizeMultiplier, limit, allowIncompleteResults, fields);
   }
 
   @VisibleForTesting
@@ -89,6 +103,7 @@
         opts.pageSize(),
         opts.pageSizeMultiplier(),
         opts.limit(),
+        opts.allowIncompleteResults(),
         opts.fields());
   }
 
@@ -157,6 +172,12 @@
 
   @Override
   public boolean match(ChangeData cd) {
+    if (index.getIndexFilter().isPresent()) {
+      // Evaluate the filter. If we pass the filter, then evaluate everything else.
+      if (!index.getIndexFilter().get().match(cd)) {
+        return false;
+      }
+    }
     Predicate<ChangeData> pred = getChild(0);
     if (source != null && fromSource.get(cd) == source && postIndexMatch(pred, cd)) {
       return true;
diff --git a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
index b52b2d1..f6a4ce1 100644
--- a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
+++ b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
@@ -89,18 +89,19 @@
   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;
-          }
+        if (RefNames.isRefsUsers(ref.getRefName()) && !RefNames.isRefsEdit(ref.getRefName())) {
           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 (event.getUpdatedRefs().stream()
+          .noneMatch(ru -> ru.getRefName().equals(RefNames.REFS_CONFIG))) {
+        // The update is in All-Users and not on refs/meta/config. So it's not a change. Return
+        // early.
+        return;
+      }
     }
 
     for (UpdatedRef ref : event.getUpdatedRefs()) {
diff --git a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
index f0f3510..1b87d27 100644
--- a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
@@ -69,7 +69,10 @@
   @Deprecated static final Schema<InternalGroup> V8 = schema(V7);
 
   // Upgrade Lucene to 7.x requires reindexing.
-  static final Schema<InternalGroup> V9 = schema(V8);
+  @Deprecated static final Schema<InternalGroup> V9 = schema(V8);
+
+  // Upgrade Lucene to 8.x requires reindexing.
+  static final Schema<InternalGroup> V10 = schema(V9);
 
   /** 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/mail/EmailFactories.java b/java/com/google/gerrit/server/mail/EmailFactories.java
new file mode 100644
index 0000000..378fb34
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/EmailFactories.java
@@ -0,0 +1,138 @@
+// 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.mail;
+
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.ChangeEmail;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.CommentChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.DeleteReviewerChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.InboundEmailRejectionEmailDecorator.InboundEmailError;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+import com.google.gerrit.server.mail.send.RegisterNewEmailDecorator;
+import com.google.gerrit.server.mail.send.ReplacePatchSetChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.StartReviewChangeEmailDecorator;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Set of factories for default email notifications.
+ *
+ * <p>To send a change related email, first {@link ChangeEmailDecorator} needs to be constructed and
+ * configured. The decorator is then passed in {@link #createChangeEmail(NameKey, Change.Id,
+ * ChangeEmailDecorator)} and finally the result to {@link #createOutgoingEmail(String,
+ * EmailDecorator)} to be sent.
+ */
+public interface EmailFactories {
+  // messageClass names used for logging and email filtering in email clients.
+  String CHANGE_ABANDONED = "abandon";
+  String ATTENTION_SET_ADDED = "addToAttentionSet";
+  String ATTENTION_SET_REMOVED = "removeFromAttentionSet";
+  String COMMENTS_ADDED = "comment";
+  String REVIEWER_DELETED = "deleteReviewer";
+  String VOTE_DELETED = "deleteVote";
+  String CHANGE_MERGED = "merged";
+  String NEW_PATCHSET_ADDED = "newpatchset";
+  String CHANGE_RESTORED = "restore";
+  String CHANGE_REVERTED = "revert";
+  String REVIEW_REQUESTED = "newchange";
+  String KEY_ADDED = "addkey";
+  String KEY_DELETED = "deletekey";
+  String PASSWORD_UPDATED = "HttpPasswordUpdate";
+  String INBOUND_EMAIL_REJECTED = "error";
+  String NEW_EMAIL_REGISTERED = "registernewemail";
+
+  /** ChangeEmail decorator that adds information about change being abandoned to the email. */
+  ChangeEmailDecorator createAbandonedChangeEmail();
+
+  /** ChangeEmail decorator that adds information about attention set change to the email. */
+  AttentionSetChangeEmailDecorator createAttentionSetChangeEmail();
+
+  /** ChangeEmail decorator that adds information about an iteration of review to the email. */
+  CommentChangeEmailDecorator createCommentChangeEmail(
+      Project.NameKey project,
+      Change.Id changeId,
+      ObjectId preUpdateMetaId,
+      Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults);
+
+  /** ChangeEmail decorator that adds information about deleted reviewer to the email. */
+  DeleteReviewerChangeEmailDecorator createDeleteReviewerChangeEmail();
+
+  /** ChangeEmail decorator that adds information about deleted vote to the email. */
+  ChangeEmailDecorator createDeleteVoteChangeEmail();
+
+  /** ChangeEmail decorator that adds information about change being merged to the email. */
+  ChangeEmailDecorator createMergedChangeEmail(Optional<String> stickyApprovalDiff);
+
+  /** ChangeEmail decorator that adds information about a new patchset added to the change. */
+  ReplacePatchSetChangeEmailDecorator createReplacePatchSetChangeEmail(
+      Project.NameKey project,
+      Change.Id changeId,
+      ChangeKind changeKind,
+      ObjectId preUpdateMetaId,
+      Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults);
+
+  /** ChangeEmail decorator that adds information about change being restored to the email. */
+  ChangeEmailDecorator createRestoredChangeEmail();
+
+  /** ChangeEmail decorator that adds information about change being reverted to the email. */
+  ChangeEmailDecorator createRevertedChangeEmail();
+
+  /**
+   * ChangeEmail decorator that adds information when change is uploaded or first sent to review.
+   */
+  StartReviewChangeEmailDecorator createStartReviewChangeEmail();
+
+  /** Base email decorator for change-related emails. */
+  ChangeEmail createChangeEmail(
+      Project.NameKey project, Change.Id changeId, ChangeEmailDecorator changeEmailDecorator);
+
+  /** Email decorator for adding a key to the account. */
+  EmailDecorator createAddKeyEmail(IdentifiedUser user, AccountSshKey sshKey);
+
+  /** Email decorator for adding gpg keys to the account. */
+  EmailDecorator createAddKeyEmail(IdentifiedUser user, List<String> gpgKeys);
+
+  /** Email decorator for adding a key to the account. */
+  EmailDecorator createDeleteKeyEmail(IdentifiedUser user, AccountSshKey sshKey);
+
+  /** Email decorator for adding gpg keys to the account. */
+  EmailDecorator createDeleteKeyEmail(IdentifiedUser user, List<String> gpgKeys);
+
+  /** Email decorator for password modification operations. */
+  EmailDecorator createHttpPasswordUpdateEmail(IdentifiedUser user, String operation);
+
+  /** Email decorator for inbound email errors. */
+  EmailDecorator createInboundEmailRejectionEmail(
+      Address to, String threadId, InboundEmailError reason);
+
+  /** Email decorator for the "new email address added" notification. */
+  RegisterNewEmailDecorator createRegisterNewEmail(String address);
+
+  /** Base class for any outgoing email. */
+  OutgoingEmail createOutgoingEmail(String messageClass, EmailDecorator emailDecorator);
+}
diff --git a/java/com/google/gerrit/server/mail/EmailModule.java b/java/com/google/gerrit/server/mail/EmailModule.java
index 50f26bb..9d641d1 100644
--- a/java/com/google/gerrit/server/mail/EmailModule.java
+++ b/java/com/google/gerrit/server/mail/EmailModule.java
@@ -15,41 +15,12 @@
 package com.google.gerrit.server.mail;
 
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.server.mail.send.AbandonedSender;
-import com.google.gerrit.server.mail.send.AddKeySender;
-import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
-import com.google.gerrit.server.mail.send.CommentSender;
-import com.google.gerrit.server.mail.send.CreateChangeSender;
-import com.google.gerrit.server.mail.send.DeleteKeySender;
-import com.google.gerrit.server.mail.send.DeleteReviewerSender;
-import com.google.gerrit.server.mail.send.DeleteVoteSender;
-import com.google.gerrit.server.mail.send.HttpPasswordUpdateSender;
-import com.google.gerrit.server.mail.send.MergedSender;
-import com.google.gerrit.server.mail.send.ModifyReviewerSender;
-import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
-import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
-import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
-import com.google.gerrit.server.mail.send.RestoredSender;
-import com.google.gerrit.server.mail.send.RevertedSender;
+import com.google.gerrit.server.mail.send.DefaultEmailFactories;
 
 public class EmailModule extends FactoryModule {
+
   @Override
   protected void configure() {
-    factory(AbandonedSender.Factory.class);
-    factory(AddKeySender.Factory.class);
-    factory(ModifyReviewerSender.Factory.class);
-    factory(CommentSender.Factory.class);
-    factory(CreateChangeSender.Factory.class);
-    factory(DeleteKeySender.Factory.class);
-    factory(DeleteReviewerSender.Factory.class);
-    factory(DeleteVoteSender.Factory.class);
-    factory(HttpPasswordUpdateSender.Factory.class);
-    factory(MergedSender.Factory.class);
-    factory(RegisterNewEmailSender.Factory.class);
-    factory(ReplacePatchSetSender.Factory.class);
-    factory(RestoredSender.Factory.class);
-    factory(RevertedSender.Factory.class);
-    factory(AddToAttentionSetSender.Factory.class);
-    factory(RemoveFromAttentionSetSender.Factory.class);
+    bind(EmailFactories.class).to(DefaultEmailFactories.class);
   }
 }
diff --git a/java/com/google/gerrit/server/mail/EmailTokenVerifier.java b/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
index ead4c06..ea55a24 100644
--- a/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
+++ b/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
@@ -16,9 +16,8 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 
-/** Verifies the token sent by {@link RegisterNewEmailSender}. */
+/** Verifies the token used by new email address verification process. */
 public interface EmailTokenVerifier {
   /**
    * Construct a token to verify an email address for a user.
diff --git a/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java b/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
index 36e801b..82ffda2 100644
--- a/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
+++ b/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
@@ -21,14 +21,13 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.config.AuthConfig;
-import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
-/** Verifies the token sent by {@link RegisterNewEmailSender}. */
+/** Verifies the token used by new email address verification process. */
 @Singleton
 public class SignedTokenEmailTokenVerifier implements EmailTokenVerifier {
   private final SignedToken emailRegistrationToken;
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 93da997..1898a98 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.mail.receive;
 
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
+import static com.google.gerrit.server.mail.EmailFactories.INBOUND_EMAIL_REJECTED;
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 import static java.util.stream.Collectors.toList;
 
@@ -56,10 +57,11 @@
 import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.mail.EmailFactories;
 import com.google.gerrit.server.mail.MailFilter;
-import com.google.gerrit.server.mail.send.InboundEmailRejectionSender;
-import com.google.gerrit.server.mail.send.InboundEmailRejectionSender.InboundEmailError;
+import com.google.gerrit.server.mail.send.InboundEmailRejectionEmailDecorator.InboundEmailError;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -79,7 +81,6 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -108,7 +109,7 @@
                   CommentForValidation.CommentType.INLINE_COMMENT);
 
   private final Emails emails;
-  private final InboundEmailRejectionSender.Factory emailRejectionSender;
+  private final EmailFactories emailFactories;
   private final RetryHelper retryHelper;
   private final ChangeMessagesUtil changeMessagesUtil;
   private final CommentsUtil commentsUtil;
@@ -127,7 +128,7 @@
   @Inject
   public MailProcessor(
       Emails emails,
-      InboundEmailRejectionSender.Factory emailRejectionSender,
+      EmailFactories emailFactories,
       RetryHelper retryHelper,
       ChangeMessagesUtil changeMessagesUtil,
       CommentsUtil commentsUtil,
@@ -143,7 +144,7 @@
       PluginSetContext<CommentValidator> commentValidators,
       MessageIdGenerator messageIdGenerator) {
     this.emails = emails;
-    this.emailRejectionSender = emailRejectionSender;
+    this.emailFactories = emailFactories;
     this.retryHelper = retryHelper;
     this.changeMessagesUtil = changeMessagesUtil;
     this.commentsUtil = commentsUtil;
@@ -228,10 +229,13 @@
 
   private void sendRejectionEmail(MailMessage message, InboundEmailError reason) {
     try {
-      InboundEmailRejectionSender emailSender =
-          emailRejectionSender.create(message.from(), message.id(), reason);
-      emailSender.setMessageId(messageIdGenerator.fromMailMessage(message));
-      emailSender.send();
+      OutgoingEmail email =
+          emailFactories.createOutgoingEmail(
+              INBOUND_EMAIL_REJECTED,
+              emailFactories.createInboundEmailRejectionEmail(
+                  message.from(), message.id(), reason));
+      email.setMessageId(messageIdGenerator.fromMailMessage(message));
+      email.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot send email to warn for an error");
     }
@@ -268,7 +272,7 @@
       // Get all comments; filter and sort them to get the original list of
       // comments from the outbound email.
       // TODO(hiesel) Also filter by original comment author.
-      Collection<HumanComment> comments =
+      List<HumanComment> comments =
           cd.publishedComments().stream()
               .filter(c -> (c.writtenOn.getTime() / 1000) == (metadata.timestamp.getTime() / 1000))
               .sorted(CommentsUtil.COMMENT_ORDER)
diff --git a/java/com/google/gerrit/server/mail/send/AbandonedChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/AbandonedChangeEmailDecorator.java
new file mode 100644
index 0000000..a1eede0
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/AbandonedChangeEmailDecorator.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+
+/** Send notice about a change being abandoned by its owner. */
+public class AbandonedChangeEmailDecorator implements ChangeEmail.ChangeEmailDecorator {
+  protected ChangeEmail changeEmail;
+  protected OutgoingEmail email;
+
+  @Override
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+    changeEmail.markAsReply();
+  }
+
+  @Override
+  public void populateEmailContent() {
+    changeEmail.addAuthors(RecipientType.TO);
+    changeEmail.ccAllApprovals();
+    changeEmail.bccStarredBy();
+    changeEmail.includeWatchers(NotifyType.ABANDONED_CHANGES);
+
+    email.appendText(email.textTemplate("Abandoned"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("AbandonedHtml"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/AbandonedSender.java b/java/com/google/gerrit/server/mail/send/AbandonedSender.java
deleted file mode 100644
index d8b20ba..0000000
--- a/java/com/google/gerrit/server/mail/send/AbandonedSender.java
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-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.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Send notice about a change being abandoned by its owner. */
-public class AbandonedSender extends ReplyToChangeSender {
-  public interface Factory extends ReplyToChangeSender.Factory<AbandonedSender> {
-    @Override
-    AbandonedSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public AbandonedSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, "abandon", ChangeEmail.newChangeData(args, project, changeId));
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    ccAllApprovals();
-    bccStarredBy();
-    includeWatchers(NotifyType.ABANDONED_CHANGES);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("Abandoned"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("AbandonedHtml"));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/AddKeyEmailDecorator.java b/java/com/google/gerrit/server/mail/send/AddKeyEmailDecorator.java
new file mode 100644
index 0000000..61e73e3
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/AddKeyEmailDecorator.java
@@ -0,0 +1,114 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+import com.google.common.base.Joiner;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+import java.util.List;
+
+/** Informs a user by email about the addition of an SSH or GPG key to their account. */
+@AutoFactory
+public class AddKeyEmailDecorator implements EmailDecorator {
+  private OutgoingEmail email;
+
+  private final IdentifiedUser user;
+  private final AccountSshKey sshKey;
+  private final List<String> gpgKeys;
+  private final MessageIdGenerator messageIdGenerator;
+
+  public AddKeyEmailDecorator(
+      @Provided MessageIdGenerator messageIdGenerator, IdentifiedUser user, AccountSshKey sshKey) {
+    this.messageIdGenerator = messageIdGenerator;
+    this.user = user;
+    this.sshKey = sshKey;
+    this.gpgKeys = null;
+  }
+
+  public AddKeyEmailDecorator(
+      @Provided MessageIdGenerator messageIdGenerator, IdentifiedUser user, List<String> gpgKeys) {
+    this.messageIdGenerator = messageIdGenerator;
+    this.user = user;
+    this.sshKey = null;
+    this.gpgKeys = gpgKeys;
+  }
+
+  @Override
+  public void init(OutgoingEmail email) {
+    this.email = email;
+
+    email.setHeader(
+        "Subject", String.format("[Gerrit Code Review] New %s Keys Added", getKeyType()));
+    email.setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
+    email.addByAccountId(RecipientType.TO, user.getAccountId());
+  }
+
+  @Override
+  public boolean shouldSendMessage() {
+    if (sshKey == null && (gpgKeys == null || gpgKeys.isEmpty())) {
+      // Don't email if no keys were added.
+      return false;
+    }
+
+    return true;
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyEmailDataParam("email", getEmail());
+    email.addSoyEmailDataParam("gpgKeys", getGpgKeys());
+    email.addSoyEmailDataParam("keyType", getKeyType());
+    email.addSoyEmailDataParam("sshKey", getSshKey());
+    email.addSoyEmailDataParam("userNameEmail", email.getUserNameEmailFor(user.getAccountId()));
+    email.addSoyEmailDataParam("sshKeysSettingsUrl", email.getSettingsUrl("ssh-keys"));
+    email.addSoyEmailDataParam("gpgKeysSettingsUrl", email.getSettingsUrl("gpg-keys"));
+
+    email.appendText(email.textTemplate("AddKey"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("AddKeyHtml"));
+    }
+  }
+
+  private String getEmail() {
+    return user.getAccount().preferredEmail();
+  }
+
+  private String getKeyType() {
+    if (sshKey != null) {
+      return "SSH";
+    } else if (gpgKeys != null) {
+      return "GPG";
+    }
+    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);
+    }
+    return null;
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/AddKeySender.java b/java/com/google/gerrit/server/mail/send/AddKeySender.java
deleted file mode 100644
index 73a46a4..0000000
--- a/java/com/google/gerrit/server/mail/send/AddKeySender.java
+++ /dev/null
@@ -1,127 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.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;
-import com.google.gerrit.server.account.AccountSshKey;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-import java.util.List;
-
-/** Sender that informs a user by email about the addition of an SSH or GPG key to their account. */
-public class AddKeySender extends OutgoingEmail {
-  public interface Factory {
-    AddKeySender create(IdentifiedUser user, AccountSshKey sshKey);
-
-    AddKeySender create(IdentifiedUser user, List<String> gpgKey);
-  }
-
-  private final IdentifiedUser user;
-  private final AccountSshKey sshKey;
-  private final List<String> gpgKeys;
-  private final MessageIdGenerator messageIdGenerator;
-
-  @AssistedInject
-  public AddKeySender(
-      EmailArguments args,
-      MessageIdGenerator messageIdGenerator,
-      @Assisted IdentifiedUser user,
-      @Assisted AccountSshKey sshKey) {
-    super(args, "addkey");
-    this.messageIdGenerator = messageIdGenerator;
-    this.user = user;
-    this.sshKey = sshKey;
-    this.gpgKeys = null;
-  }
-
-  @AssistedInject
-  public AddKeySender(
-      EmailArguments args,
-      MessageIdGenerator messageIdGenerator,
-      @Assisted IdentifiedUser user,
-      @Assisted List<String> gpgKeys) {
-    super(args, "addkey");
-    this.messageIdGenerator = messageIdGenerator;
-    this.user = user;
-    this.sshKey = null;
-    this.gpgKeys = gpgKeys;
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setHeader("Subject", String.format("[Gerrit Code Review] New %s Keys Added", getKeyType()));
-    setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
-    addByAccountId(RecipientType.TO, user.getAccountId());
-  }
-
-  @Override
-  protected boolean shouldSendMessage() {
-    if (sshKey == null && (gpgKeys == null || gpgKeys.isEmpty())) {
-      // Don't email if no keys were added.
-      return false;
-    }
-
-    return true;
-  }
-
-  @Override
-  protected void format() throws EmailException {
-    appendText(textTemplate("AddKey"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("AddKeyHtml"));
-    }
-  }
-
-  @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    soyContextEmailData.put("email", getEmail());
-    soyContextEmailData.put("gpgKeys", getGpgKeys());
-    soyContextEmailData.put("keyType", getKeyType());
-    soyContextEmailData.put("sshKey", getSshKey());
-    soyContextEmailData.put("userNameEmail", getUserNameEmailFor(user.getAccountId()));
-  }
-
-  private String getEmail() {
-    return user.getAccount().preferredEmail();
-  }
-
-  private String getKeyType() {
-    if (sshKey != null) {
-      return "SSH";
-    } else if (gpgKeys != null) {
-      return "GPG";
-    }
-    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);
-    }
-    return null;
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java b/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
deleted file mode 100644
index f9ef199..0000000
--- a/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
+++ /dev/null
@@ -1,43 +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.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Let users know of a new user in the attention set. */
-public class AddToAttentionSetSender extends AttentionSetSender {
-
-  public interface Factory extends ReplyToChangeSender.Factory<AddToAttentionSetSender> {
-    @Override
-    AddToAttentionSetSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public AddToAttentionSetSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, "addToAttentionSet", project, changeId);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("AddToAttentionSet"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("AddToAttentionSetHtml"));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/AttentionSetChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/AttentionSetChangeEmailDecorator.java
new file mode 100644
index 0000000..ad1c175
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/AttentionSetChangeEmailDecorator.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+
+/** Base class for Attention Set email senders */
+public interface AttentionSetChangeEmailDecorator extends ChangeEmailDecorator {
+  enum AttentionSetChange {
+    USER_ADDED,
+    USER_REMOVED
+  }
+
+  /** User who is being added/removed from attention set. */
+  public void setAttentionSetUser(Account.Id attentionSetUser);
+
+  /** Cause of the change in attention set. */
+  public void setReason(String reason);
+
+  /** Whether the user is being added or removed. */
+  public void setAttentionSetChange(AttentionSetChange attentionSetChange);
+}
diff --git a/java/com/google/gerrit/server/mail/send/AttentionSetChangeEmailDecoratorImpl.java b/java/com/google/gerrit/server/mail/send/AttentionSetChangeEmailDecoratorImpl.java
new file mode 100644
index 0000000..6d09a2b
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/AttentionSetChangeEmailDecoratorImpl.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+
+/** Base class for Attention Set email senders */
+public class AttentionSetChangeEmailDecoratorImpl implements AttentionSetChangeEmailDecorator {
+  protected OutgoingEmail email;
+  protected ChangeEmail changeEmail;
+
+  protected Account.Id attentionSetUser;
+  protected String reason;
+  protected AttentionSetChange attentionSetChange;
+
+  @Override
+  public void setAttentionSetUser(Account.Id attentionSetUser) {
+    this.attentionSetUser = attentionSetUser;
+  }
+
+  @Override
+  public void setReason(String reason) {
+    this.reason = reason;
+  }
+
+  @Override
+  public void setAttentionSetChange(AttentionSetChange attentionSetChange) {
+    this.attentionSetChange = attentionSetChange;
+  }
+
+  @Override
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+    changeEmail.markAsReply();
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyParam("attentionSetUser", email.getNameFor(attentionSetUser));
+    email.addSoyParam("reason", reason);
+
+    changeEmail.addAuthors(RecipientType.TO);
+    changeEmail.ccAllApprovals();
+    changeEmail.bccStarredBy();
+    changeEmail.ccExistingReviewers();
+
+    switch (attentionSetChange) {
+      case USER_ADDED:
+        email.appendText(email.textTemplate("AddToAttentionSet"));
+        if (email.useHtml()) {
+          email.appendHtml(email.soyHtmlTemplate("AddToAttentionSetHtml"));
+        }
+        break;
+      case USER_REMOVED:
+        email.appendText(email.textTemplate("RemoveFromAttentionSet"));
+        if (email.useHtml()) {
+          email.appendHtml(email.soyHtmlTemplate("RemoveFromAttentionSetHtml"));
+        }
+        break;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/AttentionSetSender.java b/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
deleted file mode 100644
index d1ee4ee..0000000
--- a/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
+++ /dev/null
@@ -1,55 +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.mail.send;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-
-/** Base class for Attention Set email senders */
-public abstract class AttentionSetSender extends ReplyToChangeSender {
-  private Account.Id attentionSetUser;
-  private String reason;
-
-  public AttentionSetSender(
-      EmailArguments args, String messageClass, Project.NameKey project, Change.Id changeId) {
-    super(args, messageClass, ChangeEmail.newChangeData(args, project, changeId));
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    ccAllApprovals();
-    bccStarredBy();
-    ccExistingReviewers();
-  }
-
-  public void setAttentionSetUser(Account.Id attentionSetUser) {
-    this.attentionSetUser = attentionSetUser;
-  }
-
-  public void setReason(String reason) {
-    this.reason = reason;
-  }
-
-  @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    soyContext.put("attentionSetUser", getNameFor(attentionSetUser));
-    soyContext.put("reason", reason);
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/BranchEmailUtils.java b/java/com/google/gerrit/server/mail/send/BranchEmailUtils.java
index acba4ea..9ceb411 100644
--- a/java/com/google/gerrit/server/mail/send/BranchEmailUtils.java
+++ b/java/com/google/gerrit/server/mail/send/BranchEmailUtils.java
@@ -23,10 +23,9 @@
 import java.util.Map;
 
 /** Contains utils for email notification related to the events on project+branch. */
-class BranchEmailUtils {
-
+public class BranchEmailUtils {
   /** Set a reasonable list id so that filters can be used to sort messages. */
-  static void setListIdHeader(OutgoingEmail email, BranchNameKey branch) {
+  public static void setListIdHeader(OutgoingEmail email, BranchNameKey branch) {
     email.setHeader(
         "List-Id",
         "<gerrit-" + branch.project().get().replace('/', '-') + "." + email.getGerritHost() + ">");
@@ -36,26 +35,23 @@
   }
 
   /** Add branch information to soy template params. */
-  static void addBranchData(OutgoingEmail email, EmailArguments args, BranchNameKey branch) {
-    Map<String, Object> soyContext = email.getSoyContext();
-    Map<String, Object> soyContextEmailData = email.getSoyContextEmailData();
-
+  public static void addBranchData(OutgoingEmail email, EmailArguments args, BranchNameKey branch) {
     String projectName = branch.project().get();
-    soyContext.put("projectName", projectName);
+    email.addSoyParam("projectName", projectName);
     // shortProjectName is the project name with the path abbreviated.
-    soyContext.put("shortProjectName", getShortProjectName(projectName));
+    email.addSoyParam("shortProjectName", getShortProjectName(projectName));
 
     // instanceAndProjectName is the instance's name followed by the abbreviated project path
-    soyContext.put(
+    email.addSoyParam(
         "instanceAndProjectName",
         getInstanceAndProjectName(args.instanceNameProvider.get(), projectName));
-    soyContext.put("addInstanceNameInSubject", args.addInstanceNameInSubject);
+    email.addSoyParam("addInstanceNameInSubject", args.addInstanceNameInSubject);
 
-    soyContextEmailData.put("sshHost", getSshHost(email.getGerritHost(), args.sshAddresses));
+    email.addSoyEmailDataParam("sshHost", getSshHost(email.getGerritHost(), args.sshAddresses));
 
     Map<String, String> branchData = new HashMap<>();
     branchData.put("shortName", branch.shortName());
-    soyContext.put("branch", branchData);
+    email.addSoyParam("branch", branchData);
 
     email.addFooter(MailHeader.PROJECT.withDelimiter() + branch.project().get());
     email.addFooter(MailHeader.BRANCH.withDelimiter() + branch.shortName());
@@ -74,7 +70,7 @@
   }
 
   /** Shortens project/repo name to only show part after the last '/'. */
-  static String getShortProjectName(String projectName) {
+  public static String getShortProjectName(String projectName) {
     int lastIndexSlash = projectName.lastIndexOf('/');
     if (lastIndexSlash == 0) {
       return projectName.substring(1); // Remove the first slash
@@ -87,7 +83,7 @@
   }
 
   /** Returns a project/repo name that includes instance as prefix. */
-  static String getInstanceAndProjectName(String instanceName, String projectName) {
+  public static String getInstanceAndProjectName(String instanceName, String projectName) {
     if (instanceName == null || instanceName.isEmpty()) {
       return getShortProjectName(projectName);
     }
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 0929594..b15a506 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -14,691 +14,130 @@
 
 package com.google.gerrit.server.mail.send;
 
-import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
-
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ListMultimap;
-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.AttentionSetUpdate;
-import com.google.gerrit.entities.BranchNameKey;
 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;
 import com.google.gerrit.entities.PatchSetInfo;
-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.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.mail.MailHeader;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
-import com.google.gerrit.server.mail.send.ProjectWatch.Watchers.WatcherList;
-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;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.GlobalPermission;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
-import java.io.IOException;
-import java.net.URI;
-import java.net.URISyntaxException;
-import java.text.MessageFormat;
 import java.time.Instant;
-import java.util.Collection;
-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;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.util.RawParseUtils;
-import org.eclipse.jgit.util.TemporaryBuffer;
 
-/** Sends an email to one or more interested parties. */
-public abstract class ChangeEmail extends OutgoingEmail {
+/** Populates an email for change related notifications. */
+public interface ChangeEmail extends OutgoingEmail.EmailDecorator {
 
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  /** Implementations of params interface populate details specific to the notification type. */
+  interface ChangeEmailDecorator {
+    /**
+     * Stores the reference to the {@link OutgoingEmail} and {@link ChangeEmail} for the subsequent
+     * calls.
+     *
+     * <p>Both init and populateEmailContent can be called multiply times in case of retries. Init
+     * is therefore responsible for clearing up any changes which are not idempotent and
+     * initializing data for use in populateEmailContent.
+     *
+     * <p>Can be used to adjust any of the behaviour of the {@link
+     * ChangeEmail#populateEmailContent}.
+     */
+    void init(OutgoingEmail email, ChangeEmail changeEmail) throws EmailException;
 
-  protected static ChangeData newChangeData(
-      EmailArguments ea, Project.NameKey project, Change.Id id) {
-    return ea.changeDataFactory.create(project, id);
-  }
+    /**
+     * Populate headers, recipients and body of the email.
+     *
+     * <p>Method operates on the email provided in the init method.
+     *
+     * <p>By default, all the contents and parameters of the email should be set in this method.
+     */
+    void populateEmailContent() throws EmailException;
 
-  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;
-  protected ListMultimap<Account.Id, String> stars;
-  protected PatchSet patchSet;
-  protected PatchSetInfo patchSetInfo;
-  protected String changeMessage;
-  protected Instant timestamp;
-  protected BranchNameKey branch;
-
-  protected ProjectState projectState;
-  private Set<Account.Id> authors;
-  private boolean emailOnlyAuthors;
-  protected boolean emailOnlyAttentionSetIfEnabled;
-  // Watchers ignore attention set rules.
-  protected Set<Account.Id> watcherAccounts = new HashSet<>();
-  // Watcher can only be an email if it's specified in notify section of ProjectConfig.
-  protected Set<Address> watcherEmails = new HashSet<>();
-
-  protected ChangeEmail(EmailArguments args, String messageClass, ChangeData changeData) {
-    super(args, messageClass);
-    this.changeData = changeData;
-    change = changeData.change();
-    emailOnlyAuthors = false;
-    emailOnlyAttentionSetIfEnabled = true;
-    currentAttentionSet = getAttentionSet();
-    branch = changeData.change().getDest();
-  }
-
-  @Override
-  public void setFrom(Account.Id id) {
-    super.setFrom(id);
-
-    // Is the from user in an email squelching group?
-    try {
-      args.permissionBackend.absentUser(id).check(GlobalPermission.EMAIL_REVIEWERS);
-    } catch (AuthException | PermissionBackendException e) {
-      emailOnlyAuthors = true;
-    }
-  }
-
-  public void setPatchSet(PatchSet ps) {
-    patchSet = ps;
-  }
-
-  public void setPatchSet(PatchSet ps, PatchSetInfo psi) {
-    patchSet = ps;
-    patchSetInfo = psi;
-  }
-
-  public void setChangeMessage(String cm, Instant t) {
-    changeMessage = cm;
-    timestamp = t;
-  }
-
-  /** Format the message body by calling {@link #appendText(String)}. */
-  @Override
-  protected void format() throws EmailException {
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("ChangeHeaderHtml"));
-    }
-    appendText(textTemplate("ChangeHeader"));
-    formatChange();
-    appendText(textTemplate("ChangeFooter"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("ChangeFooterHtml"));
-    }
-    formatFooter();
-  }
-
-  /** Format the message body by calling {@link #appendText(String)}. */
-  protected abstract void formatChange() throws EmailException;
-
-  /**
-   * Format the message footer by calling {@link #appendText(String)}.
-   *
-   * @throws EmailException if an error occurred.
-   */
-  protected void formatFooter() throws EmailException {}
-
-  /** Setup the message headers and envelope (TO, CC, BCC). */
-  @Override
-  protected void init() throws EmailException {
-    if (args.projectCache != null) {
-      projectState = args.projectCache.get(change.getProject()).orElse(null);
-    } else {
-      projectState = null;
-    }
-
-    if (patchSet == null) {
-      try {
-        patchSet = changeData.currentPatchSet();
-      } catch (StorageException err) {
-        patchSet = null;
-      }
-    }
-
-    if (patchSet != null) {
-      setHeader(MailHeader.PATCH_SET.fieldName(), patchSet.number() + "");
-      if (patchSetInfo == null) {
-        try {
-          patchSetInfo = args.patchSetInfoFactory.get(changeData.notes(), patchSet.id());
-        } catch (PatchSetInfoNotAvailableException | StorageException err) {
-          patchSetInfo = null;
-        }
-      }
-    }
-
-    try {
-      stars = changeData.stars();
-    } catch (StorageException e) {
-      throw new EmailException("Failed to load stars for change " + change.getChangeId(), e);
-    }
-
-    super.init();
-    BranchEmailUtils.setListIdHeader(this, branch);
-    if (timestamp != null) {
-      setHeader(FieldName.DATE, timestamp);
-    }
-    setChangeSubjectHeader();
-    setHeader(MailHeader.CHANGE_ID.fieldName(), "" + change.getKey().get());
-    setHeader(MailHeader.CHANGE_NUMBER.fieldName(), "" + change.getChangeId());
-    setHeader(MailHeader.PROJECT.fieldName(), "" + change.getProject());
-    setChangeUrlHeader();
-    setCommitIdHeader();
-
-    if (notify.handling().equals(NotifyHandling.OWNER_REVIEWERS)
-        || notify.handling().equals(NotifyHandling.ALL)) {
-      try {
-        changeData.reviewersByEmail().byState(ReviewerStateInternal.CC).stream()
-            .forEach(address -> addByEmail(RecipientType.CC, address));
-        changeData.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER).stream()
-            .forEach(address -> addByEmail(RecipientType.CC, address));
-      } catch (StorageException e) {
-        throw new EmailException("Failed to add unregistered CCs " + change.getChangeId(), e);
-      }
-    }
-  }
-
-  private void setChangeUrlHeader() {
-    final String u = getChangeUrl();
-    if (u != null) {
-      setHeader(MailHeader.CHANGE_URL.fieldName(), "<" + u + ">");
-    }
-  }
-
-  private void setCommitIdHeader() {
-    if (patchSet != null) {
-      setHeader(MailHeader.COMMIT.fieldName(), patchSet.commitId().name());
-    }
-  }
-
-  private void setChangeSubjectHeader() {
-    setHeader(FieldName.SUBJECT, textTemplate("ChangeSubject"));
-  }
-
-  private int getInsertionsCount() {
-    return listModifiedFiles().entrySet().stream()
-        .filter(e -> !Patch.COMMIT_MSG.equals(e.getKey()))
-        .map(Map.Entry::getValue)
-        .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() {
-    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().toEpochMilli()
-        + "."
-        + change.getKey().get()
-        + "@"
-        + getGerritHost()
-        + ">";
-  }
-
-  /** Get the text of the "cover letter". */
-  public String getCoverLetter() {
-    if (changeMessage != null) {
-      return changeMessage.trim();
-    }
-    return "";
-  }
-
-  /** Create the change message and the affected file list. */
-  public String getChangeDetail() {
-    try {
-      StringBuilder detail = new StringBuilder();
-
-      if (patchSetInfo != null) {
-        detail.append(patchSetInfo.getMessage().trim()).append("\n");
-      } else {
-        detail.append(change.getSubject().trim()).append("\n");
-      }
-
-      if (patchSet != null) {
-        detail.append("---\n");
-        // 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;
-          }
-          detail
-              .append(fileDiff.changeType().getCode())
-              .append(" ")
-              .append(
-                  FilePathAdapter.getNewPath(
-                      fileDiff.oldPath(), fileDiff.newPath(), fileDiff.changeType()))
-              .append("\n");
-        }
-        detail.append(
-            MessageFormat.format(
-                "" //
-                    + "{0,choice,0#0 files|1#1 file|1<{0} files} changed, " //
-                    + "{1,choice,0#0 insertions|1#1 insertion|1<{1} insertions}(+), " //
-                    + "{2,choice,0#0 deletions|1#1 deletion|1<{2} deletions}(-)" //
-                    + "\n",
-                modifiedFiles.size() - 1, // -1 to account for the commit message
-                getInsertionsCount(),
-                getDeletionsCount()));
-        detail.append("\n");
-      }
-      return detail.toString();
-    } catch (Exception err) {
-      logger.atWarning().withCause(err).log("Cannot format change detail");
-      return "";
-    }
-  }
-
-  /** Get the patch list corresponding to patch set patchSetId of this change. */
-  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));
-      }
-      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<>();
-    }
-  }
-
-  /** Get the patch list corresponding to this patch set. */
-  protected Map<String, FileDiffOutput> listModifiedFiles() {
-    if (patchSet != null) {
-      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");
-    }
-    return new HashMap<>();
-  }
-
-  /** Get the project entity the change is in; null if its been deleted. */
-  protected ProjectState getProjectState() {
-    return projectState;
-  }
-
-  /** TO or CC all vested parties (change owner, patch set uploader, author). */
-  protected void addAuthors(RecipientType rt) {
-    for (Account.Id id : getAuthors()) {
-      addByAccountId(rt, id);
-    }
-  }
-
-  /** BCC any user who has starred this change. */
-  protected void bccStarredBy() {
-    if (!NotifyHandling.ALL.equals(notify.handling())) {
-      return;
-    }
-
-    for (Map.Entry<Account.Id, Collection<String>> e : stars.asMap().entrySet()) {
-      if (e.getValue().contains(StarredChangesUtil.DEFAULT_LABEL)) {
-        super.addByAccountId(RecipientType.BCC, e.getKey());
-      }
-    }
-  }
-
-  /** Include users and groups that want notification of events. */
-  protected void includeWatchers(NotifyType type) {
-    includeWatchers(type, true);
-  }
-
-  /** Include users and groups that want notification of events. */
-  protected void includeWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
-    try {
-      Watchers matching = getWatchers(type, includeWatchersFromNotifyConfig);
-      addWatchers(RecipientType.TO, matching.to);
-      addWatchers(RecipientType.CC, matching.cc);
-      addWatchers(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 BCC watchers for %s", type);
-    }
-  }
-
-  /** Add users or email addresses to the TO, CC, or BCC list. */
-  private void addWatchers(RecipientType type, WatcherList watcherList) {
-    watcherAccounts.addAll(watcherList.accounts);
-    for (Account.Id user : watcherList.accounts) {
-      addByAccountId(type, user);
-    }
-
-    watcherEmails.addAll(watcherList.emails);
-    for (Address addr : watcherList.emails) {
-      addByEmail(type, addr);
-    }
-  }
-
-  private final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
-    if (!NotifyHandling.ALL.equals(notify.handling())) {
-      return new Watchers();
-    }
-
-    ProjectWatch watch = new ProjectWatch(args, branch.project(), projectState, changeData);
-    return watch.getWatchers(type, includeWatchersFromNotifyConfig);
-  }
-
-  /** Any user who has published comments on this change. */
-  protected void ccAllApprovals() {
-    if (!NotifyHandling.ALL.equals(notify.handling())
-        && !NotifyHandling.OWNER_REVIEWERS.equals(notify.handling())) {
-      return;
-    }
-
-    try {
-      for (Account.Id id : changeData.reviewers().all()) {
-        addByAccountId(RecipientType.CC, id);
-      }
-    } catch (StorageException err) {
-      logger.atWarning().withCause(err).log("Cannot CC users that reviewed updated change");
-    }
-  }
-
-  /** Users who were added as reviewers to this change. */
-  protected void ccExistingReviewers() {
-    if (!NotifyHandling.ALL.equals(notify.handling())
-        && !NotifyHandling.OWNER_REVIEWERS.equals(notify.handling())) {
-      return;
-    }
-
-    try {
-      for (Account.Id id : changeData.reviewers().byState(ReviewerStateInternal.REVIEWER)) {
-        addByAccountId(RecipientType.CC, id);
-      }
-    } catch (StorageException err) {
-      logger.atWarning().withCause(err).log("Cannot CC users that commented on updated change");
-    }
-  }
-
-  @Override
-  protected boolean isRecipientAllowed(Address addr) throws PermissionBackendException {
-    if (!projectState.statePermitsRead()) {
-      return false;
-    }
-    if (emailOnlyAuthors) {
-      return false;
-    }
-
-    // If the email is a watcher email, skip permission check. An email can only be a watcher if
-    // it is specified in notify section of ProjectConfig, so we trust that the recipient is
-    // allowed.
-    if (watcherEmails.contains(addr)) {
+    /** If returns false email is not sent to any recipients. */
+    default boolean shouldSendMessage() {
       return true;
     }
-    return args.permissionBackend
-        .user(args.anonymousUser.get())
-        .change(changeData)
-        .test(ChangePermission.READ);
   }
 
-  @Override
-  protected boolean isRecipientAllowed(Account.Id to) throws PermissionBackendException {
-    if (!projectState.statePermitsRead()) {
-      return false;
-    }
-    if (emailOnlyAuthors && !getAuthors().contains(to)) {
-      return false;
-    }
-    // Watchers ignore AttentionSet rules.
-    if (!watcherAccounts.contains(to)) {
-      Optional<AccountState> accountState = args.accountCache.get(to);
-      if (emailOnlyAttentionSetIfEnabled
-          && accountState.isPresent()
-          && accountState.get().generalPreferences().getEmailStrategy()
-              == EmailStrategy.ATTENTION_SET_ONLY
-          && !currentAttentionSet.contains(to)) {
-        return false;
-      }
-    }
+  /** Mark the email as non-first in the thread to ensure correct headers will be set */
+  void markAsReply();
 
-    return args.permissionBackend.absentUser(to).change(changeData).test(ChangePermission.READ);
-  }
+  /** Get change for which the email is being sent. */
+  Change getChange();
 
-  /** Lazily finds all users who are authors of any part of this change. */
-  protected Set<Account.Id> getAuthors() {
-    if (this.authors != null) {
-      return this.authors;
-    }
-    Set<Account.Id> authors = new HashSet<>();
-
-    switch (notify.handling()) {
-      case NONE:
-        break;
-      case ALL:
-      default:
-        if (patchSet != null) {
-          authors.add(patchSet.uploader());
-        }
-        if (patchSetInfo != null) {
-          if (patchSetInfo.getAuthor().getAccount() != null) {
-            authors.add(patchSetInfo.getAuthor().getAccount());
-          }
-          if (patchSetInfo.getCommitter().getAccount() != null) {
-            authors.add(patchSetInfo.getCommitter().getAccount());
-          }
-        }
-      // $FALL-THROUGH$
-      case OWNER_REVIEWERS:
-      case OWNER:
-        authors.add(change.getOwner());
-        break;
-    }
-
-    return this.authors = authors;
-  }
-
-  @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    BranchEmailUtils.addBranchData(this, args, branch);
-
-    soyContext.put("changeId", change.getKey().get());
-    soyContext.put("coverLetter", getCoverLetter());
-    soyContext.put("fromName", getNameFor(fromId));
-    soyContext.put("fromEmail", getNameEmailFor(fromId));
-    soyContext.put("diffLines", getDiffTemplateData(getUnifiedDiff()));
-
-    soyContextEmailData.put("unifiedDiff", getUnifiedDiff());
-    soyContextEmailData.put("changeDetail", getChangeDetail());
-    soyContextEmailData.put("changeUrl", getChangeUrl());
-    soyContextEmailData.put("includeDiff", getIncludeDiff());
-
-    Map<String, String> changeData = new HashMap<>();
-
-    String subject = change.getSubject();
-    String originalSubject = change.getOriginalSubject();
-    changeData.put("subject", subject);
-    changeData.put("originalSubject", originalSubject);
-    changeData.put("shortSubject", shortenSubject(subject));
-    changeData.put("shortOriginalSubject", shortenSubject(originalSubject));
-
-    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<>();
-    patchSetData.put("patchSetId", patchSet.number());
-    patchSetData.put("refName", patchSet.refName());
-    soyContext.put("patchSet", patchSetData);
-
-    Map<String, Object> patchSetInfoData = new HashMap<>();
-    patchSetInfoData.put("authorName", patchSetInfo.getAuthor().getName());
-    patchSetInfoData.put("authorEmail", patchSetInfo.getAuthor().getEmail());
-    soyContext.put("patchSetInfo", patchSetInfoData);
-
-    footers.add(MailHeader.CHANGE_ID.withDelimiter() + change.getKey().get());
-    footers.add(MailHeader.CHANGE_NUMBER.withDelimiter() + change.getChangeId());
-    footers.add(MailHeader.PATCH_SET.withDelimiter() + patchSet.number());
-    footers.add(MailHeader.OWNER.withDelimiter() + getNameEmailFor(change.getOwner()));
-    for (String reviewer : getEmailsByState(ReviewerStateInternal.REVIEWER)) {
-      footers.add(MailHeader.REVIEWER.withDelimiter() + reviewer);
-    }
-    for (String reviewer : getEmailsByState(ReviewerStateInternal.CC)) {
-      footers.add(MailHeader.CC.withDelimiter() + reviewer);
-    }
-    for (Account.Id attentionUser : currentAttentionSet) {
-      footers.add(MailHeader.ATTENTION.withDelimiter() + getNameEmailFor(attentionUser));
-    }
-    if (!currentAttentionSet.isEmpty()) {
-      // We need names rather than account ids / emails to make it user readable.
-      soyContext.put(
-          "attentionSet",
-          currentAttentionSet.stream().map(this::getNameFor).sorted().collect(toImmutableList()));
-    }
-  }
+  /** Get ChangeData for the change corresponding to the email. */
+  ChangeData getChangeData();
 
   /**
-   * A shortened subject is the subject limited to 72 characters, with an ellipsis if it exceeds
-   * that limit.
+   * Get Timestamp of the event causing the email.
+   *
+   * <p>Provided by {@link #setChangeMessage(String, Instant)}.
    */
-  private static String shortenSubject(String subject) {
-    if (subject.length() < 73) {
-      return subject;
-    }
-    return subject.substring(0, 69) + "...";
-  }
+  @Nullable
+  Instant getTimestamp();
 
-  private Set<String> getEmailsByState(ReviewerStateInternal state) {
-    Set<String> reviewers = new TreeSet<>();
-    try {
-      for (Account.Id who : changeData.reviewers().byState(state)) {
-        reviewers.add(getNameEmailFor(who));
-      }
-    } catch (StorageException e) {
-      logger.atWarning().withCause(e).log("Cannot get change reviewers");
-    }
-    return reviewers;
-  }
+  /** Specify PatchSet with which the notification is associated with. */
+  void setPatchSet(PatchSet ps);
 
-  private Set<Account.Id> getAttentionSet() {
-    Set<Account.Id> attentionSet = new TreeSet<>();
-    try {
-      attentionSet =
-          additionsOnly(changeData.attentionSet()).stream()
-              .map(AttentionSetUpdate::account)
-              .collect(Collectors.toSet());
-    } catch (StorageException e) {
-      logger.atWarning().withCause(e).log("Cannot get change attention set");
-    }
-    return attentionSet;
-  }
+  /** Get PatchSet if provided. */
+  @Nullable
+  PatchSet getPatchSet();
 
-  public boolean getIncludeDiff() {
-    return args.settings.includeDiff;
-  }
+  /** Specify PatchSet along with additional data. */
+  void setPatchSet(PatchSet ps, PatchSetInfo psi);
 
-  private static final int HEAP_EST_SIZE = 32 * 1024;
+  /** Specify the summary of what happened to the change. */
+  void setChangeMessage(String cm, Instant t);
+
+  /**
+   * Specify if the email should only be sent to attention set.
+   *
+   * <p>Only affects users who have corresponding option enabled in the settings.
+   */
+  void setEmailOnlyAttentionSetIfEnabled(boolean value);
+
+  /** Get the text of the "cover letter" (processed changeMessage). */
+  String getCoverLetter();
+
+  /** Get the patch list corresponding to patch set patchSetId of this change. */
+  Map<String, FileDiffOutput> listModifiedFiles(int patchSetId);
+
+  /** Get the patch list corresponding to this patch set. */
+  Map<String, FileDiffOutput> listModifiedFiles();
+
+  /** Get the number of added lines in a change. */
+  int getInsertionsCount();
+
+  /** Get the number of deleted lines in a change. */
+  int getDeletionsCount();
+
+  /** Get the project entity the change is in; null if its been deleted. */
+  ProjectState getProjectState();
+
+  /** TO or CC all vested parties (change owner, patch set uploader, author). */
+  void addAuthors(RecipientType rt);
+
+  /** BCC any user who has starred this change. */
+  void bccStarredBy();
+
+  /** Include users and groups that want notification of events. */
+  void includeWatchers(NotifyType type);
+
+  /** Include users and groups that want notification of events. */
+  void includeWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig);
+
+  /** Any user who has published comments on this change. */
+  void ccAllApprovals();
+
+  /** Users who were added as reviewers to this change. */
+  void ccExistingReviewers();
 
   /** Show patch set as unified difference. */
-  public String getUnifiedDiff() {
-    Map<String, FileDiffOutput> modifiedFiles;
-    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;
-    TemporaryBuffer.Heap buf = new TemporaryBuffer.Heap(Math.min(HEAP_EST_SIZE, maxSize), maxSize);
-    try (DiffFormatter fmt = new DiffFormatter(buf)) {
-      try (Repository git = args.server.openRepository(change.getProject())) {
-        try {
-          ObjectId oldId = modifiedFiles.values().iterator().next().oldCommitId();
-          ObjectId newId = modifiedFiles.values().iterator().next().newCommitId();
-          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);
-          return RawParseUtils.decode(buf.toByteArray());
-        } catch (IOException e) {
-          if (JGitText.get().inMemoryBufferLimitExceeded.equals(e.getMessage())) {
-            return "";
-          }
-          logger.atSevere().withCause(e).log("Cannot format patch");
-          return "";
-        }
-      } catch (IOException e) {
-        logger.atSevere().withCause(e).log("Cannot open repository to format patch");
-        return "";
-      }
-    }
-  }
+  String getUnifiedDiff();
 
   /**
    * Generate a list of maps representing each line of the unified diff. The line maps will have a
@@ -708,8 +147,7 @@
    * @param sourceDiff the unified diff that we're converting to the map.
    * @return map of 'type' to a line's content.
    */
-  protected static ImmutableList<ImmutableMap<String, String>> getDiffTemplateData(
-      String sourceDiff) {
+  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/ChangeEmailImpl.java b/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java
new file mode 100644
index 0000000..1ba5e2a
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java
@@ -0,0 +1,727 @@
+// 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.mail.send;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+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.AttentionSetUpdate;
+import com.google.gerrit.entities.BranchNameKey;
+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;
+import com.google.gerrit.entities.PatchSetInfo;
+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.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.mail.MailHeader;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
+import com.google.gerrit.server.mail.send.ProjectWatch.Watchers.WatcherList;
+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;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import java.io.IOException;
+import java.text.MessageFormat;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
+import org.apache.james.mime4j.dom.field.FieldName;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.internal.JGitText;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.eclipse.jgit.util.TemporaryBuffer;
+
+/** Populates an email for change related notifications. */
+@AutoFactory
+public class ChangeEmailImpl implements ChangeEmail {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  // Available after construction
+  protected final EmailArguments args;
+  protected final Set<Account.Id> currentAttentionSet;
+  protected final Change change;
+  protected final ChangeData changeData;
+  protected final BranchNameKey branch;
+  protected final ChangeEmailDecorator changeEmailDecorator;
+
+  // Available after init or after being explicitly set.
+  protected OutgoingEmail email;
+  private List<Account.Id> stars;
+  protected PatchSet patchSet;
+  protected PatchSetInfo patchSetInfo;
+  private String changeMessage;
+  private String changeMessageThreadId;
+  private Instant timestamp;
+  private ProjectState projectState;
+  private Set<Account.Id> authors;
+  private boolean emailOnlyAuthors;
+  private boolean emailOnlyAttentionSetIfEnabled;
+  // Watchers ignore attention set rules.
+  private Set<Account.Id> watcherAccounts = new HashSet<>();
+  // Watcher can only be an email if it's specified in notify section of ProjectConfig.
+  private Set<Address> watcherEmails = new HashSet<>();
+  private boolean isThreadReply = false;
+
+  public ChangeEmailImpl(
+      @Provided EmailArguments args,
+      Project.NameKey project,
+      Change.Id changeId,
+      ChangeEmailDecorator changeEmailDecorator) {
+    this.args = args;
+    this.changeData = args.newChangeData(project, changeId);
+    change = changeData.change();
+    emailOnlyAuthors = false;
+    emailOnlyAttentionSetIfEnabled = true;
+    currentAttentionSet = getAttentionSet();
+    branch = changeData.change().getDest();
+    this.changeEmailDecorator = changeEmailDecorator;
+  }
+
+  @Override
+  public void markAsReply() {
+    isThreadReply = true;
+  }
+
+  @Override
+  public Change getChange() {
+    return change;
+  }
+
+  @Override
+  public ChangeData getChangeData() {
+    return changeData;
+  }
+
+  @Override
+  @Nullable
+  public Instant getTimestamp() {
+    return timestamp;
+  }
+
+  @Override
+  public void setPatchSet(PatchSet ps) {
+    patchSet = ps;
+  }
+
+  @Override
+  @Nullable
+  public PatchSet getPatchSet() {
+    return patchSet;
+  }
+
+  @Override
+  public void setPatchSet(PatchSet ps, PatchSetInfo psi) {
+    patchSet = ps;
+    patchSetInfo = psi;
+  }
+
+  @Override
+  public void setChangeMessage(String cm, Instant t) {
+    changeMessage = cm;
+    timestamp = t;
+  }
+
+  @Override
+  public void setEmailOnlyAttentionSetIfEnabled(boolean value) {
+    emailOnlyAttentionSetIfEnabled = value;
+  }
+
+  @Override
+  public boolean shouldSendMessage() {
+    return changeEmailDecorator.shouldSendMessage();
+  }
+
+  @Override
+  public void init(OutgoingEmail email) throws EmailException {
+    this.email = email;
+
+    changeMessageThreadId =
+        String.format(
+            "<gerrit.%s.%s@%s>",
+            change.getCreatedOn().toEpochMilli(), change.getKey().get(), email.getGerritHost());
+
+    if (email.getFrom() != null) {
+      // Is the from user in an email squelching group?
+      try {
+        args.permissionBackend.absentUser(email.getFrom()).check(GlobalPermission.EMAIL_REVIEWERS);
+      } catch (AuthException | PermissionBackendException e) {
+        emailOnlyAuthors = true;
+      }
+    }
+
+    if (args.projectCache != null) {
+      projectState = args.projectCache.get(change.getProject()).orElse(null);
+    } else {
+      projectState = null;
+    }
+
+    if (patchSet == null) {
+      try {
+        patchSet = changeData.currentPatchSet();
+      } catch (StorageException err) {
+        patchSet = null;
+      }
+    }
+
+    if (patchSet != null) {
+      email.setHeader(MailHeader.PATCH_SET.fieldName(), patchSet.number() + "");
+      if (patchSetInfo == null) {
+        try {
+          patchSetInfo = args.patchSetInfoFactory.get(changeData.notes(), patchSet.id());
+        } catch (PatchSetInfoNotAvailableException | StorageException err) {
+          patchSetInfo = null;
+        }
+      }
+    }
+
+    try {
+      stars = changeData.stars();
+    } catch (StorageException e) {
+      throw new EmailException("Failed to load stars for change " + change.getChangeId(), e);
+    }
+
+    BranchEmailUtils.setListIdHeader(email, branch);
+    if (timestamp != null) {
+      email.setHeader(FieldName.DATE, timestamp);
+    }
+    email.setHeader(MailHeader.CHANGE_ID.fieldName(), "" + change.getKey().get());
+    email.setHeader(MailHeader.CHANGE_NUMBER.fieldName(), "" + change.getChangeId());
+    email.setHeader(MailHeader.PROJECT.fieldName(), "" + change.getProject());
+    setChangeUrlHeader();
+    setCommitIdHeader();
+
+    changeEmailDecorator.init(email, this);
+  }
+
+  private void setChangeUrlHeader() {
+    final String u = getChangeUrl();
+    if (u != null) {
+      email.setHeader(MailHeader.CHANGE_URL.fieldName(), "<" + u + ">");
+    }
+  }
+
+  private void setCommitIdHeader() {
+    if (patchSet != null) {
+      email.setHeader(MailHeader.COMMIT.fieldName(), patchSet.commitId().name());
+    }
+  }
+
+  protected void setChangeSubjectHeader() {
+    email.setHeader(FieldName.SUBJECT, email.textTemplate("ChangeSubject"));
+  }
+
+  @Override
+  public int getInsertionsCount() {
+    return listModifiedFiles().entrySet().stream()
+        .filter(e -> !Patch.COMMIT_MSG.equals(e.getKey()))
+        .map(Map.Entry::getValue)
+        .map(FileDiffOutput::insertions)
+        .reduce(0, Integer::sum);
+  }
+
+  @Override
+  public 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
+  protected String getChangeUrl() {
+    return args.urlFormatter
+        .get()
+        .getChangeViewUrl(change.getProject(), change.getId())
+        .map(EmailArguments::addUspParam)
+        .orElse(null);
+  }
+
+  /** Sets headers for conversation grouping */
+  protected void setThreadHeaders() {
+    if (isThreadReply) {
+      email.setHeader("In-Reply-To", changeMessageThreadId);
+    }
+    email.setHeader("References", changeMessageThreadId);
+  }
+
+  /** Get the text of the "cover letter". */
+  @Override
+  public String getCoverLetter() {
+    if (changeMessage != null) {
+      return changeMessage.trim();
+    }
+    return "";
+  }
+
+  /** Create the change message and the affected file list. */
+  protected String getChangeDetail() {
+    try {
+      StringBuilder detail = new StringBuilder();
+
+      if (patchSetInfo != null) {
+        detail.append(patchSetInfo.getMessage().trim()).append("\n");
+      } else {
+        detail.append(change.getSubject().trim()).append("\n");
+      }
+
+      if (patchSet != null) {
+        detail.append("---\n");
+        // 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;
+          }
+          detail
+              .append(fileDiff.changeType().getCode())
+              .append(" ")
+              .append(
+                  FilePathAdapter.getNewPath(
+                      fileDiff.oldPath(), fileDiff.newPath(), fileDiff.changeType()))
+              .append("\n");
+        }
+        detail.append(
+            MessageFormat.format(
+                "" //
+                    + "{0,choice,0#0 files|1#1 file|1<{0} files} changed, " //
+                    + "{1,choice,0#0 insertions|1#1 insertion|1<{1} insertions}(+), " //
+                    + "{2,choice,0#0 deletions|1#1 deletion|1<{2} deletions}(-)" //
+                    + "\n",
+                modifiedFiles.size() - 1, // -1 to account for the commit message
+                getInsertionsCount(),
+                getDeletionsCount()));
+        detail.append("\n");
+      }
+      return detail.toString();
+    } catch (Exception err) {
+      logger.atWarning().withCause(err).log("Cannot format change detail");
+      return "";
+    }
+  }
+
+  /** Get the patch list corresponding to patch set patchSetId of this change. */
+  @Override
+  public 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));
+      }
+      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<>();
+    }
+  }
+
+  /** Get the patch list corresponding to this patch set. */
+  @Override
+  public Map<String, FileDiffOutput> listModifiedFiles() {
+    if (patchSet != null) {
+      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");
+    }
+    return new HashMap<>();
+  }
+
+  /** Get the project entity the change is in; null if its been deleted. */
+  @Override
+  public ProjectState getProjectState() {
+    return projectState;
+  }
+
+  /** TO or CC all vested parties (change owner, patch set uploader, author). */
+  @Override
+  public void addAuthors(RecipientType rt) {
+    for (Account.Id id : getAuthors()) {
+      email.addByAccountId(rt, id);
+    }
+  }
+
+  /** BCC any user who has starred this change. */
+  @Override
+  public void bccStarredBy() {
+    if (!NotifyHandling.ALL.equals(email.getNotify().handling())) {
+      return;
+    }
+
+    stars.forEach(accountId -> email.addByAccountId(RecipientType.BCC, accountId));
+  }
+
+  /** Include users and groups that want notification of events. */
+  @Override
+  public void includeWatchers(NotifyType type) {
+    includeWatchers(type, true);
+  }
+
+  /** Include users and groups that want notification of events. */
+  @Override
+  public void includeWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
+    try {
+      Watchers matching = getWatchers(type, includeWatchersFromNotifyConfig);
+      addWatchers(RecipientType.TO, matching.to);
+      addWatchers(RecipientType.CC, matching.cc);
+      addWatchers(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 BCC watchers for %s", type);
+    }
+  }
+
+  /** Add users or email addresses to the TO, CC, or BCC list. */
+  private void addWatchers(RecipientType type, WatcherList watcherList) {
+    watcherAccounts.addAll(watcherList.accounts);
+    for (Account.Id user : watcherList.accounts) {
+      email.addByAccountId(type, user);
+    }
+
+    watcherEmails.addAll(watcherList.emails);
+    for (Address addr : watcherList.emails) {
+      email.addByEmail(type, addr);
+    }
+  }
+
+  private final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
+    if (!NotifyHandling.ALL.equals(email.getNotify().handling())) {
+      return new Watchers();
+    }
+
+    ProjectWatch watch = new ProjectWatch(args, branch.project(), projectState, changeData);
+    return watch.getWatchers(type, includeWatchersFromNotifyConfig);
+  }
+
+  /** Any user who has published comments on this change. */
+  @Override
+  public void ccAllApprovals() {
+    if (!NotifyHandling.ALL.equals(email.getNotify().handling())
+        && !NotifyHandling.OWNER_REVIEWERS.equals(email.getNotify().handling())) {
+      return;
+    }
+
+    try {
+      for (Account.Id id : changeData.reviewers().all()) {
+        email.addByAccountId(RecipientType.CC, id);
+      }
+    } catch (StorageException err) {
+      logger.atWarning().withCause(err).log("Cannot CC users that reviewed updated change");
+    }
+  }
+
+  /** Users who were added as reviewers to this change. */
+  @Override
+  public void ccExistingReviewers() {
+    if (!NotifyHandling.ALL.equals(email.getNotify().handling())
+        && !NotifyHandling.OWNER_REVIEWERS.equals(email.getNotify().handling())) {
+      return;
+    }
+
+    try {
+      for (Account.Id id : changeData.reviewers().byState(ReviewerStateInternal.REVIEWER)) {
+        email.addByAccountId(RecipientType.CC, id);
+      }
+    } catch (StorageException err) {
+      logger.atWarning().withCause(err).log("Cannot CC users that commented on updated change");
+    }
+  }
+
+  @Override
+  public boolean isRecipientAllowed(Address addr) throws PermissionBackendException {
+    if (!projectState.statePermitsRead()) {
+      return false;
+    }
+    if (emailOnlyAuthors) {
+      return false;
+    }
+
+    // If the email is a watcher email, skip permission check. An email can only be a watcher if
+    // it is specified in notify section of ProjectConfig, so we trust that the recipient is
+    // allowed.
+    if (watcherEmails.contains(addr)) {
+      return true;
+    }
+    return args.permissionBackend
+        .user(args.anonymousUser.get())
+        .change(changeData)
+        .test(ChangePermission.READ);
+  }
+
+  @Override
+  public boolean isRecipientAllowed(Account.Id to) throws PermissionBackendException {
+    if (!projectState.statePermitsRead()) {
+      return false;
+    }
+    if (emailOnlyAuthors && !getAuthors().contains(to)) {
+      return false;
+    }
+    // Watchers ignore AttentionSet rules.
+    if (!watcherAccounts.contains(to)) {
+      Optional<AccountState> accountState = args.accountCache.get(to);
+      if (emailOnlyAttentionSetIfEnabled
+          && accountState.isPresent()
+          && accountState.get().generalPreferences().getEmailStrategy()
+              == EmailStrategy.ATTENTION_SET_ONLY
+          && !currentAttentionSet.contains(to)) {
+        return false;
+      }
+    }
+    return args.permissionBackend.absentUser(to).change(changeData).test(ChangePermission.READ);
+  }
+
+  /** Lazily finds all users who are authors of any part of this change. */
+  private Set<Account.Id> getAuthors() {
+    if (this.authors != null) {
+      return this.authors;
+    }
+    Set<Account.Id> authors = new HashSet<>();
+
+    switch (email.getNotify().handling()) {
+      case NONE:
+        break;
+      case ALL:
+      default:
+        if (patchSet != null) {
+          authors.add(patchSet.uploader());
+        }
+        if (patchSetInfo != null) {
+          if (patchSetInfo.getAuthor().getAccount() != null) {
+            authors.add(patchSetInfo.getAuthor().getAccount());
+          }
+          if (patchSetInfo.getCommitter().getAccount() != null) {
+            authors.add(patchSetInfo.getCommitter().getAccount());
+          }
+        }
+      // $FALL-THROUGH$
+      case OWNER_REVIEWERS:
+      case OWNER:
+        authors.add(change.getOwner());
+        break;
+    }
+
+    return this.authors = authors;
+  }
+
+  @Override
+  public void populateEmailContent() throws EmailException {
+    BranchEmailUtils.addBranchData(email, args, branch);
+    setThreadHeaders();
+
+    email.addSoyParam("changeId", change.getKey().get());
+    email.addSoyParam("coverLetter", getCoverLetter());
+    email.addSoyParam("fromName", email.getNameFor(email.getFrom()));
+    email.addSoyParam("fromEmail", email.getNameEmailFor(email.getFrom()));
+    email.addSoyParam("diffLines", ChangeEmail.getDiffTemplateData(getUnifiedDiff()));
+
+    email.addSoyEmailDataParam("unifiedDiff", getUnifiedDiff());
+    email.addSoyEmailDataParam("changeDetail", getChangeDetail());
+    email.addSoyEmailDataParam("changeUrl", getChangeUrl());
+    email.addSoyEmailDataParam("includeDiff", getIncludeDiff());
+
+    Map<String, String> changeData = new HashMap<>();
+
+    String subject = change.getSubject();
+    String originalSubject = change.getOriginalSubject();
+    changeData.put("subject", subject);
+    changeData.put("originalSubject", originalSubject);
+    changeData.put("shortSubject", shortenSubject(subject));
+    changeData.put("shortOriginalSubject", shortenSubject(originalSubject));
+
+    changeData.put("ownerName", email.getNameFor(change.getOwner()));
+    changeData.put("ownerEmail", email.getNameEmailFor(change.getOwner()));
+    changeData.put("changeNumber", Integer.toString(change.getChangeId()));
+    changeData.put(
+        "sizeBucket",
+        ChangeSizeBucket.getChangeSizeBucket(getInsertionsCount() + getDeletionsCount()));
+    email.addSoyParam("change", changeData);
+
+    Map<String, Object> patchSetData = new HashMap<>();
+    patchSetData.put("patchSetId", patchSet.number());
+    patchSetData.put("refName", patchSet.refName());
+    email.addSoyParam("patchSet", patchSetData);
+
+    Map<String, Object> patchSetInfoData = new HashMap<>();
+    patchSetInfoData.put("authorName", patchSetInfo.getAuthor().getName());
+    patchSetInfoData.put("authorEmail", patchSetInfo.getAuthor().getEmail());
+    email.addSoyParam("patchSetInfo", patchSetInfoData);
+
+    email.addFooter(MailHeader.CHANGE_ID.withDelimiter() + change.getKey().get());
+    email.addFooter(MailHeader.CHANGE_NUMBER.withDelimiter() + change.getChangeId());
+    email.addFooter(MailHeader.PATCH_SET.withDelimiter() + patchSet.number());
+    email.addFooter(MailHeader.OWNER.withDelimiter() + email.getNameEmailFor(change.getOwner()));
+    for (String reviewer : getEmailsByState(ReviewerStateInternal.REVIEWER)) {
+      email.addFooter(MailHeader.REVIEWER.withDelimiter() + reviewer);
+    }
+    for (String reviewer : getEmailsByState(ReviewerStateInternal.CC)) {
+      email.addFooter(MailHeader.CC.withDelimiter() + reviewer);
+    }
+    for (Account.Id attentionUser : currentAttentionSet) {
+      email.addFooter(MailHeader.ATTENTION.withDelimiter() + email.getNameEmailFor(attentionUser));
+    }
+    // We need names rather than account ids / emails to make it user readable.
+    email.addSoyParam(
+        "attentionSet",
+        currentAttentionSet.stream().map(email::getNameFor).sorted().collect(toImmutableList()));
+
+    setChangeSubjectHeader();
+    if (email.getNotify().handling().equals(NotifyHandling.OWNER_REVIEWERS)
+        || email.getNotify().handling().equals(NotifyHandling.ALL)) {
+      try {
+        this.changeData.reviewersByEmail().byState(ReviewerStateInternal.CC).stream()
+            .forEach(address -> email.addByEmail(RecipientType.CC, address));
+        this.changeData.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER).stream()
+            .forEach(address -> email.addByEmail(RecipientType.CC, address));
+      } catch (StorageException e) {
+        throw new EmailException("Failed to add unregistered CCs " + change.getChangeId(), e);
+      }
+    }
+
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("ChangeHeaderHtml"));
+    }
+    email.appendText(email.textTemplate("ChangeHeader"));
+    changeEmailDecorator.populateEmailContent();
+    email.appendText(email.textTemplate("ChangeFooter"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("ChangeFooterHtml"));
+    }
+  }
+
+  /**
+   * A shortened subject is the subject limited to 72 characters, with an ellipsis if it exceeds
+   * that limit.
+   */
+  protected static String shortenSubject(String subject) {
+    if (subject.length() < 73) {
+      return subject;
+    }
+    return subject.substring(0, 69) + "...";
+  }
+
+  protected Set<String> getEmailsByState(ReviewerStateInternal state) {
+    Set<String> reviewers = new TreeSet<>();
+    try {
+      for (Account.Id who : changeData.reviewers().byState(state)) {
+        reviewers.add(email.getNameEmailFor(who));
+      }
+    } catch (StorageException e) {
+      logger.atWarning().withCause(e).log("Cannot get change reviewers");
+    }
+    return reviewers;
+  }
+
+  private Set<Account.Id> getAttentionSet() {
+    Set<Account.Id> attentionSet = new TreeSet<>();
+    try {
+      attentionSet =
+          additionsOnly(changeData.attentionSet()).stream()
+              .map(AttentionSetUpdate::account)
+              .collect(Collectors.toSet());
+    } catch (StorageException e) {
+      logger.atWarning().withCause(e).log("Cannot get change attention set");
+    }
+    return attentionSet;
+  }
+
+  protected boolean getIncludeDiff() {
+    return args.settings.includeDiff;
+  }
+
+  private static final int HEAP_EST_SIZE = 32 * 1024;
+
+  /** Show patch set as unified difference. */
+  @Override
+  public String getUnifiedDiff() {
+    Map<String, FileDiffOutput> modifiedFiles;
+    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;
+    TemporaryBuffer.Heap buf = new TemporaryBuffer.Heap(Math.min(HEAP_EST_SIZE, maxSize), maxSize);
+    try (DiffFormatter fmt = new DiffFormatter(buf)) {
+      try (Repository git = args.server.openRepository(change.getProject())) {
+        try {
+          ObjectId oldId = modifiedFiles.values().iterator().next().oldCommitId();
+          ObjectId newId = modifiedFiles.values().iterator().next().newCommitId();
+          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);
+          return RawParseUtils.decode(buf.toByteArray());
+        } catch (IOException e) {
+          if (JGitText.get().inMemoryBufferLimitExceeded.equals(e.getMessage())) {
+            return "";
+          }
+          logger.atSevere().withCause(e).log("Cannot format patch");
+          return "";
+        }
+      } catch (IOException e) {
+        logger.atSevere().withCause(e).log("Cannot open repository to format patch");
+        return "";
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/CommentChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/CommentChangeEmailDecorator.java
new file mode 100644
index 0000000..00cce24
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/CommentChangeEmailDecorator.java
@@ -0,0 +1,33 @@
+// 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.mail.send;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+import com.google.gerrit.server.util.LabelVote;
+import java.util.List;
+
+/** Send comments, after the author of them hit used Publish Comments in the UI. */
+public interface CommentChangeEmailDecorator extends ChangeEmailDecorator {
+  /** List of comments added as part of review iteration. */
+  void setComments(List<? extends Comment> comments);
+
+  /** Patchset-level comment attached to review iteration. */
+  void setPatchSetComment(@Nullable String comment);
+
+  /** List of votes set in the review iteration. */
+  void setLabels(List<LabelVote> labels);
+}
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentChangeEmailDecoratorImpl.java
similarity index 77%
rename from java/com/google/gerrit/server/mail/send/CommentSender.java
rename to java/com/google/gerrit/server/mail/send/CommentChangeEmailDecoratorImpl.java
index 3711ca2..c54c488 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentChangeEmailDecoratorImpl.java
@@ -18,6 +18,8 @@
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static java.util.stream.Collectors.toList;
 
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.common.base.Strings;
 import com.google.common.base.Supplier;
 import com.google.common.base.Suppliers;
@@ -35,10 +37,10 @@
 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;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.mail.MailProcessingUtil;
 import com.google.gerrit.server.CommentsUtil;
@@ -47,8 +49,6 @@
 import com.google.gerrit.server.patch.PatchFile;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.util.LabelVote;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
@@ -66,20 +66,11 @@
 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 {
+@AutoFactory
+public class CommentChangeEmailDecoratorImpl implements CommentChangeEmailDecorator {
+  protected static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public interface Factory {
-
-    CommentSender create(
-        Project.NameKey project,
-        Change.Id changeId,
-        ObjectId preUpdateMetaId,
-        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults);
-  }
-
-  private class FileCommentGroup {
+  protected class FileCommentGroup {
 
     public String filename;
     public int patchSetId;
@@ -89,19 +80,31 @@
     /** 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);
+      return args.urlFormatter
+          .get()
+          .getInlineCommentView(changeEmail.getChange(), uuid)
+          .map(EmailArguments::addUspParam)
+          .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);
+      return args.urlFormatter
+          .get()
+          .getCommentsTabView(changeEmail.getChange())
+          .map(EmailArguments::addUspParam)
+          .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);
+      return args.urlFormatter
+          .get()
+          .getFindingsTabView(changeEmail.getChange())
+          .map(EmailArguments::addUspParam)
+          .orElse(null);
     }
 
     /**
@@ -120,27 +123,28 @@
     }
   }
 
-  private List<? extends Comment> inlineComments = Collections.emptyList();
-  @Nullable private String patchSetComment;
-  private ImmutableList<LabelVote> labels = ImmutableList.of();
-  private final CommentsUtil commentsUtil;
+  protected EmailArguments args;
+  protected OutgoingEmail email;
+  protected ChangeEmail changeEmail;
+  protected List<? extends Comment> inlineComments = Collections.emptyList();
+  @Nullable protected String patchSetComment;
+  protected List<LabelVote> labels = ImmutableList.of();
+  protected 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(
-      EmailArguments args,
-      CommentsUtil commentsUtil,
-      @GerritServerConfig Config cfg,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id changeId,
-      @Assisted ObjectId preUpdateMetaId,
-      @Assisted
-          Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
-    super(args, "comment", newChangeData(args, project, changeId));
+  public CommentChangeEmailDecoratorImpl(
+      @Provided EmailArguments args,
+      @Provided CommentsUtil commentsUtil,
+      @Provided @GerritServerConfig Config cfg,
+      Project.NameKey project,
+      Change.Id changeId,
+      ObjectId preUpdateMetaId,
+      Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
+    this.args = args;
     this.commentsUtil = commentsUtil;
     this.incomingEmailEnabled =
         cfg.getEnum("receiveemail", null, "protocol", Protocol.NONE).ordinal()
@@ -151,72 +155,52 @@
             () ->
                 // 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)
+                args.newChangeData(project, changeId, preUpdateMetaId)
                     .submitRequirementsIncludingLegacy());
     this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
   }
 
+  @Override
   public void setComments(List<? extends Comment> comments) {
     inlineComments = comments;
   }
 
+  @Override
   public void setPatchSetComment(@Nullable String comment) {
     this.patchSetComment = comment;
   }
 
-  public void setLabels(ImmutableList<LabelVote> labels) {
+  @Override
+  public void setLabels(List<LabelVote> labels) {
     this.labels = labels;
   }
 
   @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    if (notify.handling().equals(NotifyHandling.OWNER_REVIEWERS)
-        || notify.handling().equals(NotifyHandling.ALL)) {
-      ccAllApprovals();
-    }
-    if (notify.handling().equals(NotifyHandling.ALL)) {
-      bccStarredBy();
-      includeWatchers(NotifyType.ALL_COMMENTS, !change.isWorkInProgress() && !change.isPrivate());
-    }
-
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
     // Add header that enables identifying comments on parsed email.
     // Grouping is currently done by timestamp.
-    setHeader(MailHeader.COMMENT_DATE.fieldName(), timestamp);
+    email.setHeader(MailHeader.COMMENT_DATE.fieldName(), changeEmail.getTimestamp());
 
     if (incomingEmailEnabled) {
       if (replyToAddress == null) {
         // Remove Reply-To and use outbound SMTP (default) instead.
-        removeHeader(FieldName.REPLY_TO);
+        email.removeHeader(FieldName.REPLY_TO);
       } else {
-        setHeader(FieldName.REPLY_TO, replyToAddress);
+        email.setHeader(FieldName.REPLY_TO, replyToAddress);
       }
     }
-  }
-
-  @Override
-  public void formatChange() throws EmailException {
-    appendText(textTemplate("Comment"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("CommentHtml"));
-    }
-  }
-
-  @Override
-  public void formatFooter() throws EmailException {
-    appendText(textTemplate("CommentFooter"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("CommentFooterHtml"));
-    }
+    changeEmail.markAsReply();
   }
 
   /**
    * Returns a list of FileCommentGroup objects representing the inline comments grouped by the
    * file.
    */
-  private List<CommentSender.FileCommentGroup> getGroupedInlineComments(Repository repo) {
-    List<CommentSender.FileCommentGroup> groups = new ArrayList<>();
+  private List<CommentChangeEmailDecoratorImpl.FileCommentGroup> getGroupedInlineComments(
+      Repository repo) {
+    List<CommentChangeEmailDecoratorImpl.FileCommentGroup> groups = new ArrayList<>();
 
     // Loop over the comments and collect them into groups based on the file
     // location of the comment.
@@ -230,7 +214,7 @@
         currentGroup.filename = c.key.filename;
         currentGroup.patchSetId = c.key.patchSetId;
         // Get the modified files:
-        Map<String, FileDiffOutput> modifiedFiles = listModifiedFiles(c.key.patchSetId);
+        Map<String, FileDiffOutput> modifiedFiles = changeEmail.listModifiedFiles(c.key.patchSetId);
 
         groups.add(currentGroup);
         if (modifiedFiles != null && !modifiedFiles.isEmpty()) {
@@ -241,7 +225,7 @@
                 "Cannot load %s from %s in %s",
                 c.key.filename,
                 modifiedFiles.values().iterator().next().newCommitId().name(),
-                projectState.getName());
+                changeEmail.getProjectState().getName());
             currentGroup.fileData = null;
           }
         }
@@ -257,7 +241,7 @@
   }
 
   /** Get the set of accounts whose comments have been replied to in this email. */
-  private HashSet<Account.Id> getReplyAccounts() {
+  protected HashSet<Account.Id> getReplyAccounts() {
     HashSet<Account.Id> replyAccounts = new HashSet<>();
     // Track visited parent UUIDs to avoid cycles.
     HashSet<String> visitedUuids = new HashSet<>();
@@ -326,7 +310,7 @@
     }
     Comment.Key key = new Comment.Key(child.parentUuid, child.key.filename, child.key.patchSetId);
     try {
-      return commentsUtil.getPublishedHumanComment(changeData.notes(), key);
+      return commentsUtil.getPublishedHumanComment(changeEmail.getChangeData().notes(), key);
     } catch (StorageException e) {
       logger.atWarning().log("Could not find the parent of this comment: %s", child);
       return Optional.empty();
@@ -361,7 +345,7 @@
    * or the first line, or following the last period within the first 100 characters, whichever is
    * shorter. If the message is shortened, an ellipsis is appended.
    */
-  protected static String getShortenedCommentMessage(String message) {
+  static String getShortenedCommentMessage(String message) {
     int threshold = 100;
     String fullMessage = message.trim();
     String msg = fullMessage;
@@ -390,7 +374,7 @@
     return msg;
   }
 
-  protected static String getShortenedCommentMessage(Comment comment) {
+  static String getShortenedCommentMessage(Comment comment) {
     return getShortenedCommentMessage(comment.message);
   }
 
@@ -401,7 +385,7 @@
   private List<Map<String, Object>> getCommentGroupsTemplateData(Repository repo) {
     List<Map<String, Object>> commentGroups = new ArrayList<>();
 
-    for (CommentSender.FileCommentGroup group : getGroupedInlineComments(repo)) {
+    for (CommentChangeEmailDecoratorImpl.FileCommentGroup group : getGroupedInlineComments(repo)) {
       Map<String, Object> groupData = new HashMap<>();
       groupData.put("title", group.getTitle());
       groupData.put("patchSetId", group.patchSetId);
@@ -472,7 +456,7 @@
     return commentGroups;
   }
 
-  private List<Map<String, Object>> commentBlocksToSoyData(List<CommentFormatter.Block> blocks) {
+  protected List<Map<String, Object>> commentBlocksToSoyData(List<CommentFormatter.Block> blocks) {
     return blocks.stream()
         .map(
             b -> {
@@ -510,47 +494,68 @@
   }
 
   @Nullable
-  private Repository getRepository() {
+  protected Repository getRepository() {
     try {
-      return args.server.openRepository(projectState.getNameKey());
+      return args.server.openRepository(changeEmail.getProjectState().getNameKey());
     } catch (IOException e) {
       return null;
     }
   }
 
   @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
+  public void populateEmailContent() {
+    changeEmail.addAuthors(RecipientType.TO);
+
     boolean hasComments;
     try (Repository repo = getRepository()) {
       List<Map<String, Object>> files = getCommentGroupsTemplateData(repo);
-      soyContext.put("commentFiles", files);
+      email.addSoyParam("commentFiles", files);
       hasComments = !files.isEmpty();
     }
 
-    soyContext.put(
+    email.addSoyParam(
         "patchSetCommentBlocks", commentBlocksToSoyData(CommentFormatter.parse(patchSetComment)));
-    soyContext.put("labels", getLabelVoteSoyData(labels));
-    soyContext.put("commentCount", inlineComments.size());
-    soyContext.put("commentTimestamp", getCommentTimestamp());
-    soyContext.put(
-        "coverLetterBlocks", commentBlocksToSoyData(CommentFormatter.parse(getCoverLetter())));
+    email.addSoyParam("labels", getLabelVoteSoyData(labels));
+    email.addSoyParam("commentCount", inlineComments.size());
+    email.addSoyParam("commentTimestamp", getCommentTimestamp());
+    email.addSoyParam(
+        "coverLetterBlocks",
+        commentBlocksToSoyData(CommentFormatter.parse(changeEmail.getCoverLetter())));
 
     if (isChangeNoLongerSubmittable()) {
-      soyContext.put("unsatisfiedSubmitRequirements", formatUnsatisfiedSubmitRequirements());
-      soyContext.put(
+      email.addSoyParam("unsatisfiedSubmitRequirements", formatUnsatisfiedSubmitRequirements());
+      email.addSoyParam(
           "oldSubmitRequirements",
-          formatSubmitRequirments(preUpdateSubmitRequirementResultsSupplier.get()));
-      soyContext.put(
-          "newSubmitRequirements", formatSubmitRequirments(postUpdateSubmitRequirementResults));
+          formatSubmitRequirements(preUpdateSubmitRequirementResultsSupplier.get()));
+      email.addSoyParam(
+          "newSubmitRequirements", formatSubmitRequirements(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"));
+    email.addFooter(MailHeader.COMMENT_DATE.withDelimiter() + getCommentTimestamp());
+    email.addFooter(MailHeader.HAS_COMMENTS.withDelimiter() + (hasComments ? "Yes" : "No"));
+    email.addFooter(MailHeader.HAS_LABELS.withDelimiter() + (labels.isEmpty() ? "No" : "Yes"));
 
     for (Account.Id account : getReplyAccounts()) {
-      footers.add(MailHeader.COMMENT_IN_REPLY_TO.withDelimiter() + getNameEmailFor(account));
+      email.addFooter(
+          MailHeader.COMMENT_IN_REPLY_TO.withDelimiter() + email.getNameEmailFor(account));
+    }
+
+    if (email.getNotify().handling().equals(NotifyHandling.OWNER_REVIEWERS)
+        || email.getNotify().handling().equals(NotifyHandling.ALL)) {
+      changeEmail.ccAllApprovals();
+    }
+    if (email.getNotify().handling().equals(NotifyHandling.ALL)) {
+      changeEmail.bccStarredBy();
+      changeEmail.includeWatchers(
+          NotifyType.ALL_COMMENTS,
+          !changeEmail.getChange().isWorkInProgress() && !changeEmail.getChange().isPrivate());
+    }
+
+    email.appendText(email.textTemplate("Comment"));
+    email.appendText(email.textTemplate("CommentFooter"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("CommentHtml"));
+      email.appendHtml(email.soyHtmlTemplate("CommentFooterHtml"));
     }
   }
 
@@ -566,7 +571,7 @@
             .allMatch(SubmitRequirementResult::fulfilled);
     logger.atFine().log(
         "the submitability of change %s before the update is %s",
-        change.getId(), isSubmittablePreUpdate);
+        changeEmail.getChange().getId(), isSubmittablePreUpdate);
     if (!isSubmittablePreUpdate) {
       return false;
     }
@@ -576,7 +581,7 @@
             .allMatch(SubmitRequirementResult::fulfilled);
     logger.atFine().log(
         "the submitability of change %s after the update is %s",
-        change.getId(), isSubmittablePostUpdate);
+        changeEmail.getChange().getId(), isSubmittablePostUpdate);
     return !isSubmittablePostUpdate;
   }
 
@@ -589,7 +594,7 @@
         .collect(toImmutableList());
   }
 
-  private static ImmutableList<String> formatSubmitRequirments(
+  private static ImmutableList<String> formatSubmitRequirements(
       Map<SubmitRequirement, SubmitRequirementResult> submitRequirementResults) {
     return submitRequirementResults.entrySet().stream()
         .map(
@@ -607,7 +612,7 @@
         .collect(toImmutableList());
   }
 
-  private String getLine(PatchFile fileInfo, short side, int lineNbr) {
+  protected String getLine(PatchFile fileInfo, short side, int lineNbr) {
     try {
       return fileInfo.getLine(side, lineNbr);
     } catch (IOException err) {
@@ -627,7 +632,7 @@
     }
   }
 
-  private ImmutableList<Map<String, Object>> getLabelVoteSoyData(ImmutableList<LabelVote> votes) {
+  private ImmutableList<Map<String, Object>> getLabelVoteSoyData(List<LabelVote> votes) {
     ImmutableList.Builder<Map<String, Object>> result = ImmutableList.builder();
     for (LabelVote vote : votes) {
       Map<String, Object> data = new HashMap<>();
@@ -641,9 +646,9 @@
     return result.build();
   }
 
-  private String getCommentTimestamp() {
+  protected String getCommentTimestamp() {
     // Grouping is currently done by timestamp.
     return MailProcessingUtil.rfcDateformatter.format(
-        ZonedDateTime.ofInstant(timestamp, ZoneId.of("UTC")));
+        ZonedDateTime.ofInstant(changeEmail.getTimestamp(), 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
deleted file mode 100644
index e327d4d..0000000
--- a/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-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.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Notify interested parties of a brand new change. */
-public class CreateChangeSender extends NewChangeSender {
-  public interface Factory {
-    CreateChangeSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public CreateChangeSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, newChangeData(args, project, changeId));
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    includeWatchers(NotifyType.NEW_CHANGES, !change.isWorkInProgress() && !change.isPrivate());
-    includeWatchers(NotifyType.NEW_PATCHSETS, !change.isWorkInProgress() && !change.isPrivate());
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/DefaultEmailFactories.java b/java/com/google/gerrit/server/mail/send/DefaultEmailFactories.java
new file mode 100644
index 0000000..ce586fc
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/DefaultEmailFactories.java
@@ -0,0 +1,178 @@
+// 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.mail.send;
+
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.Change;
+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.IdentifiedUser;
+import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.server.mail.EmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.InboundEmailRejectionEmailDecorator.InboundEmailError;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Default versions of Gerrit email notifications. */
+@Singleton
+public class DefaultEmailFactories implements EmailFactories {
+  private final CommentChangeEmailDecoratorImplFactory commentChangeEmailFactory;
+  private final MergedChangeEmailDecoratorFactory mergedChangeEmailFactory;
+  private final ReplacePatchSetChangeEmailDecoratorImplFactory replacePatchSetChangeEmailFactory;
+  private final ChangeEmailImplFactory changeEmailFactory;
+  private final AddKeyEmailDecoratorFactory addKeyEmailFactory;
+  private final DeleteKeyEmailDecoratorFactory deleteKeyEmailFactory;
+  private final HttpPasswordUpdateEmailDecoratorFactory httpPasswordUpdateEmailFactory;
+  private final RegisterNewEmailDecoratorImplFactory registerNewEmailFactory;
+  private final OutgoingEmailFactory outgoingEmailFactory;
+
+  @Inject
+  DefaultEmailFactories(
+      CommentChangeEmailDecoratorImplFactory commentChangeEmailFactory,
+      MergedChangeEmailDecoratorFactory mergedChangeEmailFactory,
+      ReplacePatchSetChangeEmailDecoratorImplFactory replacePatchSetChangeEmailFactory,
+      ChangeEmailImplFactory changeEmailFactory,
+      AddKeyEmailDecoratorFactory addKeyEmailFactory,
+      DeleteKeyEmailDecoratorFactory deleteKeyEmailFactory,
+      HttpPasswordUpdateEmailDecoratorFactory httpPasswordUpdateEmailFactory,
+      RegisterNewEmailDecoratorImplFactory registerNewEmailFactory,
+      OutgoingEmailFactory outgoingEmailFactory) {
+    this.commentChangeEmailFactory = commentChangeEmailFactory;
+    this.mergedChangeEmailFactory = mergedChangeEmailFactory;
+    this.replacePatchSetChangeEmailFactory = replacePatchSetChangeEmailFactory;
+    this.changeEmailFactory = changeEmailFactory;
+    this.addKeyEmailFactory = addKeyEmailFactory;
+    this.deleteKeyEmailFactory = deleteKeyEmailFactory;
+    this.httpPasswordUpdateEmailFactory = httpPasswordUpdateEmailFactory;
+    this.registerNewEmailFactory = registerNewEmailFactory;
+    this.outgoingEmailFactory = outgoingEmailFactory;
+  }
+
+  @Override
+  public ChangeEmailDecorator createAbandonedChangeEmail() {
+    return new AbandonedChangeEmailDecorator();
+  }
+
+  @Override
+  public AttentionSetChangeEmailDecorator createAttentionSetChangeEmail() {
+    return new AttentionSetChangeEmailDecoratorImpl();
+  }
+
+  @Override
+  public CommentChangeEmailDecorator createCommentChangeEmail(
+      Project.NameKey project,
+      Change.Id changeId,
+      ObjectId preUpdateMetaId,
+      Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
+    return commentChangeEmailFactory.create(
+        project, changeId, preUpdateMetaId, postUpdateSubmitRequirementResults);
+  }
+
+  @Override
+  public DeleteReviewerChangeEmailDecorator createDeleteReviewerChangeEmail() {
+    return new DeleteReviewerChangeEmailDecoratorImpl();
+  }
+
+  @Override
+  public ChangeEmailDecorator createDeleteVoteChangeEmail() {
+    return new DeleteVoteChangeEmailDecorator();
+  }
+
+  @Override
+  public ChangeEmailDecorator createMergedChangeEmail(Optional<String> stickyApprovalDiff) {
+    return mergedChangeEmailFactory.create(stickyApprovalDiff);
+  }
+
+  @Override
+  public ReplacePatchSetChangeEmailDecorator createReplacePatchSetChangeEmail(
+      Project.NameKey project,
+      Change.Id changeId,
+      ChangeKind changeKind,
+      ObjectId preUpdateMetaId,
+      Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
+    return replacePatchSetChangeEmailFactory.create(
+        project, changeId, changeKind, preUpdateMetaId, postUpdateSubmitRequirementResults);
+  }
+
+  @Override
+  public ChangeEmailDecorator createRestoredChangeEmail() {
+    return new RestoredChangeEmailDecorator();
+  }
+
+  @Override
+  public ChangeEmailDecorator createRevertedChangeEmail() {
+    return new RevertedChangeEmailDecorator();
+  }
+
+  @Override
+  public StartReviewChangeEmailDecorator createStartReviewChangeEmail() {
+    return new StartReviewChangeEmailDecoratorImpl();
+  }
+
+  @Override
+  public ChangeEmail createChangeEmail(
+      Project.NameKey project, Change.Id changeId, ChangeEmailDecorator changeEmailDecorator) {
+    return changeEmailFactory.create(project, changeId, changeEmailDecorator);
+  }
+
+  @Override
+  public EmailDecorator createAddKeyEmail(IdentifiedUser user, AccountSshKey sshKey) {
+    return addKeyEmailFactory.create(user, sshKey);
+  }
+
+  @Override
+  public EmailDecorator createAddKeyEmail(IdentifiedUser user, List<String> gpgKeys) {
+    return addKeyEmailFactory.create(user, gpgKeys);
+  }
+
+  @Override
+  public EmailDecorator createDeleteKeyEmail(IdentifiedUser user, AccountSshKey sshKey) {
+    return deleteKeyEmailFactory.create(user, sshKey);
+  }
+
+  @Override
+  public EmailDecorator createDeleteKeyEmail(IdentifiedUser user, List<String> gpgKeys) {
+    return deleteKeyEmailFactory.create(user, gpgKeys);
+  }
+
+  @Override
+  public EmailDecorator createHttpPasswordUpdateEmail(IdentifiedUser user, String operation) {
+    return httpPasswordUpdateEmailFactory.create(user, operation);
+  }
+
+  @Override
+  public EmailDecorator createInboundEmailRejectionEmail(
+      Address to, String threadId, InboundEmailError reason) {
+    return new InboundEmailRejectionEmailDecorator(to, threadId, reason);
+  }
+
+  @Override
+  public RegisterNewEmailDecorator createRegisterNewEmail(String address) {
+    return registerNewEmailFactory.create(address);
+  }
+
+  @Override
+  public OutgoingEmail createOutgoingEmail(String messageClass, EmailDecorator emailDecorator) {
+    return outgoingEmailFactory.create(messageClass, emailDecorator);
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/DeleteKeyEmailDecorator.java b/java/com/google/gerrit/server/mail/send/DeleteKeyEmailDecorator.java
new file mode 100644
index 0000000..b2228f5
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/DeleteKeyEmailDecorator.java
@@ -0,0 +1,106 @@
+// 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.mail.send;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+import com.google.common.base.Joiner;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+import java.util.Collections;
+import java.util.List;
+
+/** Informs a user by email about the removal of an SSH or GPG key from their account. */
+@AutoFactory
+public class DeleteKeyEmailDecorator implements EmailDecorator {
+  private OutgoingEmail email;
+
+  private final IdentifiedUser user;
+  private final AccountSshKey sshKey;
+  private final List<String> gpgKeyFingerprints;
+  private final MessageIdGenerator messageIdGenerator;
+
+  public DeleteKeyEmailDecorator(
+      @Provided MessageIdGenerator messageIdGenerator, IdentifiedUser user, AccountSshKey sshKey) {
+    this.messageIdGenerator = messageIdGenerator;
+    this.user = user;
+    this.gpgKeyFingerprints = Collections.emptyList();
+    this.sshKey = sshKey;
+  }
+
+  public DeleteKeyEmailDecorator(
+      @Provided MessageIdGenerator messageIdGenerator,
+      IdentifiedUser user,
+      List<String> gpgKeyFingerprints) {
+    this.messageIdGenerator = messageIdGenerator;
+    this.user = user;
+    this.gpgKeyFingerprints = gpgKeyFingerprints;
+    this.sshKey = null;
+  }
+
+  @Override
+  public void init(OutgoingEmail email) {
+    this.email = email;
+
+    email.setHeader("Subject", String.format("[Gerrit Code Review] %s Keys Deleted", getKeyType()));
+    email.setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
+    email.addByAccountId(RecipientType.TO, user.getAccountId());
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyEmailDataParam("email", getEmail());
+    email.addSoyEmailDataParam("gpgKeyFingerprints", getGpgKeyFingerprints());
+    email.addSoyEmailDataParam("keyType", getKeyType());
+    email.addSoyEmailDataParam("sshKey", getSshKey());
+    email.addSoyEmailDataParam("userNameEmail", email.getUserNameEmailFor(user.getAccountId()));
+    email.addSoyEmailDataParam("sshKeysSettingsUrl", email.getSettingsUrl("ssh-keys"));
+    email.addSoyEmailDataParam("gpgKeysSettingsUrl", email.getSettingsUrl("gpg-keys"));
+
+    email.appendText(email.textTemplate("DeleteKey"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("DeleteKeyHtml"));
+    }
+  }
+
+  private String getEmail() {
+    return user.getAccount().preferredEmail();
+  }
+
+  private String getKeyType() {
+    if (sshKey != null) {
+      return "SSH";
+    } else if (gpgKeyFingerprints != null) {
+      return "GPG";
+    }
+    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);
+    }
+    return null;
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
deleted file mode 100644
index 22c26b1..0000000
--- a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
+++ /dev/null
@@ -1,125 +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.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;
-import com.google.gerrit.server.account.AccountSshKey;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-import java.util.Collections;
-import java.util.List;
-
-/**
- * Sender that informs a user by email about the removal of an SSH or GPG key from their account.
- */
-public class DeleteKeySender extends OutgoingEmail {
-  public interface Factory {
-    DeleteKeySender create(IdentifiedUser user, AccountSshKey sshKey);
-
-    DeleteKeySender create(IdentifiedUser user, List<String> gpgKeyFingerprints);
-  }
-
-  private final IdentifiedUser user;
-  private final AccountSshKey sshKey;
-  private final List<String> gpgKeyFingerprints;
-  private final MessageIdGenerator messageIdGenerator;
-
-  @AssistedInject
-  public DeleteKeySender(
-      EmailArguments args,
-      MessageIdGenerator messageIdGenerator,
-      @Assisted IdentifiedUser user,
-      @Assisted AccountSshKey sshKey) {
-    super(args, "deletekey");
-    this.messageIdGenerator = messageIdGenerator;
-    this.user = user;
-    this.gpgKeyFingerprints = Collections.emptyList();
-    this.sshKey = sshKey;
-  }
-
-  @AssistedInject
-  public DeleteKeySender(
-      EmailArguments args,
-      MessageIdGenerator messageIdGenerator,
-      @Assisted IdentifiedUser user,
-      @Assisted List<String> gpgKeyFingerprints) {
-    super(args, "deletekey");
-    this.messageIdGenerator = messageIdGenerator;
-    this.user = user;
-    this.gpgKeyFingerprints = gpgKeyFingerprints;
-    this.sshKey = null;
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setHeader("Subject", String.format("[Gerrit Code Review] %s Keys Deleted", getKeyType()));
-    setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
-    addByAccountId(RecipientType.TO, user.getAccountId());
-  }
-
-  @Override
-  protected boolean shouldSendMessage() {
-    return true;
-  }
-
-  @Override
-  protected void format() throws EmailException {
-    appendText(textTemplate("DeleteKey"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("DeleteKeyHtml"));
-    }
-  }
-
-  @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    soyContextEmailData.put("email", getEmail());
-    soyContextEmailData.put("gpgKeyFingerprints", getGpgKeyFingerprints());
-    soyContextEmailData.put("keyType", getKeyType());
-    soyContextEmailData.put("sshKey", getSshKey());
-    soyContextEmailData.put("userNameEmail", getUserNameEmailFor(user.getAccountId()));
-  }
-
-  private String getEmail() {
-    return user.getAccount().preferredEmail();
-  }
-
-  private String getKeyType() {
-    if (sshKey != null) {
-      return "SSH";
-    } else if (gpgKeyFingerprints != null) {
-      return "GPG";
-    }
-    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);
-    }
-    return null;
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerChangeEmailDecorator.java
new file mode 100644
index 0000000..48c800d
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/DeleteReviewerChangeEmailDecorator.java
@@ -0,0 +1,29 @@
+// 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.mail.send;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+import java.util.Collection;
+
+/** Let users know that a reviewer and possibly her review have been removed. */
+public interface DeleteReviewerChangeEmailDecorator extends ChangeEmailDecorator {
+  /** Reviewers being deleted. */
+  void addReviewers(Collection<Account.Id> cc);
+
+  /** Reviewers by email (non-account) that are being deleted. */
+  void addReviewersByEmail(Collection<Address> cc);
+}
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerChangeEmailDecoratorImpl.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerChangeEmailDecoratorImpl.java
new file mode 100644
index 0000000..8953318
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/DeleteReviewerChangeEmailDecoratorImpl.java
@@ -0,0 +1,85 @@
+// 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.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.NotifyConfig.NotifyType;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/** Let users know that a reviewer and possibly her review have been removed. */
+public class DeleteReviewerChangeEmailDecoratorImpl implements DeleteReviewerChangeEmailDecorator {
+  protected OutgoingEmail email;
+  protected ChangeEmail changeEmail;
+
+  protected final Set<Account.Id> reviewers = new HashSet<>();
+  protected final Set<Address> reviewersByEmail = new HashSet<>();
+
+  @Override
+  public void addReviewers(Collection<Account.Id> cc) {
+    reviewers.addAll(cc);
+  }
+
+  @Override
+  public void addReviewersByEmail(Collection<Address> cc) {
+    reviewersByEmail.addAll(cc);
+  }
+
+  @Nullable
+  protected List<String> getReviewerNames() {
+    if (reviewers.isEmpty() && reviewersByEmail.isEmpty()) {
+      return null;
+    }
+    List<String> names = new ArrayList<>();
+    for (Account.Id id : reviewers) {
+      names.add(email.getNameFor(id));
+    }
+    for (Address a : reviewersByEmail) {
+      names.add(a.toString());
+    }
+    return names;
+  }
+
+  @Override
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+    changeEmail.markAsReply();
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyEmailDataParam("reviewerNames", getReviewerNames());
+
+    changeEmail.addAuthors(RecipientType.TO);
+    changeEmail.ccAllApprovals();
+    changeEmail.bccStarredBy();
+    changeEmail.ccExistingReviewers();
+    changeEmail.includeWatchers(NotifyType.ALL_COMMENTS);
+    reviewers.stream().forEach(r -> email.addByAccountId(RecipientType.TO, r));
+    reviewersByEmail.stream().forEach(address -> email.addByEmail(RecipientType.TO, address));
+
+    email.appendText(email.textTemplate("DeleteReviewer"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("DeleteReviewerHtml"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
deleted file mode 100644
index 52a16ac..0000000
--- a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
+++ /dev/null
@@ -1,97 +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.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;
-import com.google.gerrit.entities.NotifyConfig.NotifyType;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-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.Set;
-
-/** Let users know that a reviewer and possibly her review have been removed. */
-public class DeleteReviewerSender extends ReplyToChangeSender {
-  private final Set<Account.Id> reviewers = new HashSet<>();
-  private final Set<Address> reviewersByEmail = new HashSet<>();
-
-  public interface Factory extends ReplyToChangeSender.Factory<DeleteReviewerSender> {
-    @Override
-    DeleteReviewerSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public DeleteReviewerSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, "deleteReviewer", newChangeData(args, project, changeId));
-  }
-
-  public void addReviewers(Collection<Account.Id> cc) {
-    reviewers.addAll(cc);
-  }
-
-  public void addReviewersByEmail(Collection<Address> cc) {
-    reviewersByEmail.addAll(cc);
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    ccAllApprovals();
-    bccStarredBy();
-    ccExistingReviewers();
-    includeWatchers(NotifyType.ALL_COMMENTS);
-    reviewers.stream().forEach(r -> addByAccountId(RecipientType.TO, r));
-    reviewersByEmail.stream().forEach(address -> addByEmail(RecipientType.TO, address));
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("DeleteReviewer"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("DeleteReviewerHtml"));
-    }
-  }
-
-  @Nullable
-  public List<String> getReviewerNames() {
-    if (reviewers.isEmpty() && reviewersByEmail.isEmpty()) {
-      return null;
-    }
-    List<String> names = new ArrayList<>();
-    for (Account.Id id : reviewers) {
-      names.add(getNameFor(id));
-    }
-    for (Address a : reviewersByEmail) {
-      names.add(a.toString());
-    }
-    return names;
-  }
-
-  @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    soyContextEmailData.put("reviewerNames", getReviewerNames());
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/DeleteVoteChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/DeleteVoteChangeEmailDecorator.java
new file mode 100644
index 0000000..9949a04
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/DeleteVoteChangeEmailDecorator.java
@@ -0,0 +1,45 @@
+// 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.mail.send;
+
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+
+/** Send notice about a vote that was removed from a change. */
+public class DeleteVoteChangeEmailDecorator implements ChangeEmailDecorator {
+  protected OutgoingEmail email;
+  protected ChangeEmail changeEmail;
+
+  @Override
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+    changeEmail.markAsReply();
+  }
+
+  @Override
+  public void populateEmailContent() {
+    changeEmail.addAuthors(RecipientType.TO);
+    changeEmail.ccAllApprovals();
+    changeEmail.bccStarredBy();
+    changeEmail.includeWatchers(NotifyType.ALL_COMMENTS);
+
+    email.appendText(email.textTemplate("DeleteVote"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("DeleteVoteHtml"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java b/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
deleted file mode 100644
index f71cc00..0000000
--- a/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
+++ /dev/null
@@ -1,53 +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.mail.send;
-
-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.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Send notice about a vote that was removed from a change. */
-public class DeleteVoteSender extends ReplyToChangeSender {
-  public interface Factory extends ReplyToChangeSender.Factory<DeleteVoteSender> {
-    @Override
-    DeleteVoteSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  protected DeleteVoteSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, "deleteVote", newChangeData(args, project, changeId));
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    ccAllApprovals();
-    bccStarredBy();
-    includeWatchers(NotifyType.ALL_COMMENTS);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("DeleteVote"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("DeleteVoteHtml"));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/EmailArguments.java b/java/com/google/gerrit/server/mail/send/EmailArguments.java
index 96effc1..578b775 100644
--- a/java/com/google/gerrit/server/mail/send/EmailArguments.java
+++ b/java/com/google/gerrit/server/mail/send/EmailArguments.java
@@ -14,7 +14,10 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.AnonymousUser;
@@ -49,55 +52,58 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.template.soy.jbcsrc.api.SoySauce;
+import java.net.URI;
+import java.net.URISyntaxException;
 import java.util.List;
+import org.apache.http.client.utils.URIBuilder;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 
 /**
  * Arguments used for sending notification emails.
  *
- * <p>Notification emails are sent by out by {@link OutgoingEmail} and it's subclasses, so called
- * senders. To construct an email the sender class needs to get various other classes injected.
- * Instead of injecting these classes into the sender classes directly, they only get {@code
- * EmailArguments} injected and {@code EmailArguments} provides them all dependencies that they
- * need.
+ * <p>Notification emails are sent by out by {@link OutgoingEmail} . To construct an email class (or
+ * its decorators) needs to get various other classes injected. Instead of injecting these classes
+ * into the sender classes directly, they only get {@code EmailArguments} injected and {@code
+ * EmailArguments} provides them all dependencies that they need.
  *
  * <p>This class is public because plugins need access to it for sending emails.
  */
 @Singleton
 @UsedAt(UsedAt.Project.PLUGINS_ALL)
 public class EmailArguments {
-  final GitRepositoryManager server;
-  final ProjectCache projectCache;
-  final PermissionBackend permissionBackend;
-  final GroupBackend groupBackend;
-  final AccountCache accountCache;
-  final DiffOperations diffOperations;
-  final PatchSetUtil patchSetUtil;
-  final ApprovalsUtil approvalsUtil;
-  final Provider<FromAddressGenerator> fromAddressGenerator;
-  final EmailSender emailSender;
-  final PatchSetInfoFactory patchSetInfoFactory;
-  final IdentifiedUser.GenericFactory identifiedUserFactory;
-  final ChangeNotes.Factory changeNotesFactory;
-  final Provider<AnonymousUser> anonymousUser;
-  final String anonymousCowardName;
-  final Provider<PersonIdent> gerritPersonIdent;
-  final DynamicItem<UrlFormatter> urlFormatter;
-  final AllProjectsName allProjectsName;
-  final List<String> sshAddresses;
-  final SitePaths site;
-  final Provider<ChangeQueryBuilder> queryBuilder;
-  final ChangeData.Factory changeDataFactory;
-  final Provider<SoySauce> soySauce;
-  final EmailSettings settings;
-  final DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners;
-  final Provider<InternalAccountQuery> accountQueryProvider;
-  final OutgoingEmailValidator validator;
-  final boolean addInstanceNameInSubject;
-  final Provider<String> instanceNameProvider;
-  final Provider<CurrentUser> currentUserProvider;
-  final RetryHelper retryHelper;
+  public final GitRepositoryManager server;
+  public final ProjectCache projectCache;
+  public final PermissionBackend permissionBackend;
+  public final GroupBackend groupBackend;
+  public final AccountCache accountCache;
+  public final DiffOperations diffOperations;
+  public final PatchSetUtil patchSetUtil;
+  public final ApprovalsUtil approvalsUtil;
+  public final Provider<FromAddressGenerator> fromAddressGenerator;
+  public final EmailSender emailSender;
+  public final PatchSetInfoFactory patchSetInfoFactory;
+  public final IdentifiedUser.GenericFactory identifiedUserFactory;
+  public final ChangeNotes.Factory changeNotesFactory;
+  public final Provider<AnonymousUser> anonymousUser;
+  public final String anonymousCowardName;
+  public final Provider<PersonIdent> gerritPersonIdent;
+  public final DynamicItem<UrlFormatter> urlFormatter;
+  public final AllProjectsName allProjectsName;
+  public final List<String> sshAddresses;
+  public final SitePaths site;
+  public final Provider<ChangeQueryBuilder> queryBuilder;
+  public final ChangeData.Factory changeDataFactory;
+  public final Provider<SoySauce> soySauce;
+  public final EmailSettings settings;
+  public final DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners;
+  public final Provider<InternalAccountQuery> accountQueryProvider;
+  public final OutgoingEmailValidator validator;
+  public final boolean addInstanceNameInSubject;
+  public final Provider<String> instanceNameProvider;
+  public final Provider<CurrentUser> currentUserProvider;
+  public final RetryHelper retryHelper;
 
   @Inject
   EmailArguments(
@@ -164,4 +170,24 @@
     this.currentUserProvider = currentUserProvider;
     this.retryHelper = retryHelper;
   }
+
+  /** Fetch ChangeData for the specified change. */
+  public ChangeData newChangeData(Project.NameKey project, Change.Id id) {
+    return changeDataFactory.create(project, id);
+  }
+
+  /** Fetch ChangeData for specified change and revision. */
+  public ChangeData newChangeData(Project.NameKey project, Change.Id id, ObjectId metaId) {
+    return changeDataFactory.create(changeNotesFactory.createChecked(project, id, metaId));
+  }
+
+  @Nullable
+  public static String addUspParam(String url) {
+    try {
+      URI uri = new URIBuilder(url).addParameter("usp", "email").build();
+      return uri.toString();
+    } catch (URISyntaxException e) {
+      return null;
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/EmailResource.java b/java/com/google/gerrit/server/mail/send/EmailResource.java
new file mode 100644
index 0000000..169ab4b
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/EmailResource.java
@@ -0,0 +1,39 @@
+// 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.mail.send;
+
+import com.google.auto.value.AutoValue;
+import com.google.protobuf.ByteString;
+
+/**
+ * Email resource that can be attached to an email.
+ *
+ * <p>Can be used for images included in html body of the email.
+ */
+@AutoValue
+public abstract class EmailResource {
+  public static EmailResource create(String contentId, String contentType, ByteString content) {
+    return new AutoValue_EmailResource(contentId, contentType, content);
+  }
+
+  /** Value of Content-ID header used for referring to the resource from html body of the email. */
+  public abstract String contentId();
+
+  /** MIME type of the resource. */
+  public abstract String contentType();
+
+  /** Unencoded data that should be added to the email */
+  public abstract ByteString content();
+}
diff --git a/java/com/google/gerrit/server/mail/send/EmailSender.java b/java/com/google/gerrit/server/mail/send/EmailSender.java
index 711ab1b..51629c6 100644
--- a/java/com/google/gerrit/server/mail/send/EmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/EmailSender.java
@@ -57,6 +57,34 @@
   }
 
   /**
+   * Sends an email message. Messages always contain a text body, but messages can optionally
+   * include an additional HTML body and related resources. If both body types are present, {@code
+   * send} should construct a {@code multipart/alternative} message with an appropriately-selected
+   * boundary. If the HTML Resources are provided then html body and corresponding resources should
+   * be grouped as {@code multipart/related}.
+   *
+   * @param from who the message is from.
+   * @param rcpt one or more address where the message will be delivered to. This list overrides any
+   *     To or CC headers in {@code headers}.
+   * @param headers message headers.
+   * @param textBody text to appear in the {@code text/plain} body of the message.
+   * @param htmlBody optional HTML code to appear in the {@code text/html} body of the message.
+   * @param htmlResources optional resources that can be referenced in HTML code using their {@link
+   *     EmailResource#contentId}.
+   * @throws EmailException the message cannot be sent.
+   */
+  default void send(
+      Address from,
+      Collection<Address> rcpt,
+      Map<String, EmailHeader> headers,
+      String textBody,
+      @Nullable String htmlBody,
+      Collection<EmailResource> htmlResources)
+      throws EmailException {
+    send(from, rcpt, headers, textBody, htmlBody);
+  }
+
+  /**
    * Sends an email message with a text body only (i.e. not HTML or multipart).
    *
    * <p>Authors of new implementations of this interface should not use this method to send a
diff --git a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateEmailDecorator.java b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateEmailDecorator.java
new file mode 100644
index 0000000..af265a6
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateEmailDecorator.java
@@ -0,0 +1,67 @@
+// 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.mail.send;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+import com.google.gerrit.server.util.time.TimeUtil;
+
+/** Sender that informs a user by email that the HTTP password of their account was updated. */
+@AutoFactory
+public class HttpPasswordUpdateEmailDecorator implements EmailDecorator {
+  private OutgoingEmail email;
+
+  private final IdentifiedUser user;
+  private final String operation;
+  private final MessageIdGenerator messageIdGenerator;
+
+  public HttpPasswordUpdateEmailDecorator(
+      @Provided MessageIdGenerator messageIdGenerator, IdentifiedUser user, String operation) {
+    this.messageIdGenerator = messageIdGenerator;
+    this.user = user;
+    this.operation = operation;
+  }
+
+  @Override
+  public void init(OutgoingEmail email) {
+    this.email = email;
+
+    email.setHeader("Subject", "[Gerrit Code Review] HTTP password was " + operation);
+    email.setMessageId(
+        messageIdGenerator.fromReasonAccountIdAndTimestamp(
+            "HTTP_password_change", user.getAccountId(), TimeUtil.now()));
+    email.addByAccountId(RecipientType.TO, user.getAccountId());
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyEmailDataParam("email", getEmail());
+    email.addSoyEmailDataParam("userNameEmail", email.getUserNameEmailFor(user.getAccountId()));
+    email.addSoyEmailDataParam("operation", operation);
+    email.addSoyEmailDataParam("httpPasswordSettingsUrl", email.getSettingsUrl("http-password"));
+
+    email.appendText(email.textTemplate("HttpPasswordUpdate"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("HttpPasswordUpdateHtml"));
+    }
+  }
+
+  private String getEmail() {
+    return user.getAccount().preferredEmail();
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
deleted file mode 100644
index 5fb66bb..0000000
--- a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
+++ /dev/null
@@ -1,81 +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.mail.send;
-
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-
-/** Sender that informs a user by email that the HTTP password of their account was updated. */
-public class HttpPasswordUpdateSender extends OutgoingEmail {
-  public interface Factory {
-    HttpPasswordUpdateSender create(IdentifiedUser user, String operation);
-  }
-
-  private final IdentifiedUser user;
-  private final String operation;
-  private final MessageIdGenerator messageIdGenerator;
-
-  @AssistedInject
-  public HttpPasswordUpdateSender(
-      EmailArguments args,
-      MessageIdGenerator messageIdGenerator,
-      @Assisted IdentifiedUser user,
-      @Assisted String operation) {
-    super(args, "HttpPasswordUpdate");
-    this.messageIdGenerator = messageIdGenerator;
-    this.user = user;
-    this.operation = operation;
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setHeader("Subject", "[Gerrit Code Review] HTTP password was " + operation);
-    setMessageId(
-        messageIdGenerator.fromReasonAccountIdAndTimestamp(
-            "HTTP_password_change", user.getAccountId(), TimeUtil.now()));
-    addByAccountId(RecipientType.TO, user.getAccountId());
-  }
-
-  @Override
-  protected boolean shouldSendMessage() {
-    // Always send an email if the HTTP password is updated.
-    return true;
-  }
-
-  @Override
-  protected void format() throws EmailException {
-    appendText(textTemplate("HttpPasswordUpdate"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("HttpPasswordUpdateHtml"));
-    }
-  }
-
-  @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    soyContextEmailData.put("email", getEmail());
-    soyContextEmailData.put("userNameEmail", getUserNameEmailFor(user.getAccountId()));
-    soyContextEmailData.put("operation", operation);
-  }
-
-  private String getEmail() {
-    return user.getAccount().preferredEmail();
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionEmailDecorator.java b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionEmailDecorator.java
new file mode 100644
index 0000000..1f8fd78
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionEmailDecorator.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.mail.MailHeader;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+import org.apache.james.mime4j.dom.field.FieldName;
+
+/** Send an email to inform users that parsing their inbound email failed. */
+public class InboundEmailRejectionEmailDecorator implements EmailDecorator {
+
+  /** Used by the templating system to determine what error message should be sent */
+  public enum InboundEmailError {
+    PARSING_ERROR,
+    INACTIVE_ACCOUNT,
+    UNKNOWN_ACCOUNT,
+    INTERNAL_EXCEPTION,
+    COMMENT_REJECTED,
+    CHANGE_NOT_FOUND
+  }
+
+  private OutgoingEmail email;
+  private final Address to;
+  private final InboundEmailError reason;
+  private final String threadId;
+
+  public InboundEmailRejectionEmailDecorator(
+      Address to, String threadId, InboundEmailError reason) {
+    this.to = requireNonNull(to);
+    this.threadId = requireNonNull(threadId);
+    this.reason = requireNonNull(reason);
+  }
+
+  @Override
+  public void init(OutgoingEmail email) {
+    this.email = email;
+
+    setListIdHeader();
+    email.setHeader(FieldName.SUBJECT, "[Gerrit Code Review] Unable to process your email");
+
+    if (!threadId.isEmpty()) {
+      email.setHeader(MailHeader.REFERENCES.fieldName(), threadId);
+    }
+  }
+
+  private void setListIdHeader() {
+    // Set a reasonable list id so that filters can be used to sort messages
+    email.setHeader("List-Id", "<gerrit-noreply." + email.getGerritHost() + ">");
+    if (email.getSettingsUrl() != null) {
+      email.setHeader("List-Unsubscribe", "<" + email.getSettingsUrl() + ">");
+    }
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addByEmail(RecipientType.TO, to);
+
+    email.appendText(email.textTemplate("InboundEmailRejection_" + reason.name()));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("InboundEmailRejectionHtml_" + reason.name()));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
deleted file mode 100644
index 0ddb0ad..0000000
--- a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
+++ /dev/null
@@ -1,94 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import static java.util.Objects.requireNonNull;
-
-import com.google.gerrit.entities.Address;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.mail.MailHeader;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import org.apache.james.mime4j.dom.field.FieldName;
-
-/** Send an email to inform users that parsing their inbound email failed. */
-public class InboundEmailRejectionSender extends OutgoingEmail {
-
-  /** Used by the templating system to determine what error message should be sent */
-  public enum InboundEmailError {
-    PARSING_ERROR,
-    INACTIVE_ACCOUNT,
-    UNKNOWN_ACCOUNT,
-    INTERNAL_EXCEPTION,
-    COMMENT_REJECTED,
-    CHANGE_NOT_FOUND
-  }
-
-  public interface Factory {
-    InboundEmailRejectionSender create(Address to, String threadId, InboundEmailError reason);
-  }
-
-  private final Address to;
-  private final InboundEmailError reason;
-  private final String threadId;
-
-  @Inject
-  public InboundEmailRejectionSender(
-      EmailArguments args,
-      @Assisted Address to,
-      @Assisted String threadId,
-      @Assisted InboundEmailError reason) {
-    super(args, "error");
-    this.to = requireNonNull(to);
-    this.threadId = requireNonNull(threadId);
-    this.reason = requireNonNull(reason);
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setListIdHeader();
-    setHeader(FieldName.SUBJECT, "[Gerrit Code Review] Unable to process your email");
-
-    addByEmail(RecipientType.TO, to);
-
-    if (!threadId.isEmpty()) {
-      setHeader(MailHeader.REFERENCES.fieldName(), threadId);
-    }
-  }
-
-  private void setListIdHeader() {
-    // Set a reasonable list id so that filters can be used to sort messages
-    setHeader("List-Id", "<gerrit-noreply." + getGerritHost() + ">");
-    if (getSettingsUrl() != null) {
-      setHeader("List-Unsubscribe", "<" + getSettingsUrl() + ">");
-    }
-  }
-
-  @Override
-  protected void format() throws EmailException {
-    appendText(textTemplate("InboundEmailRejection_" + reason.name()));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("InboundEmailRejectionHtml_" + reason.name()));
-    }
-  }
-
-  @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    footers.add(MailHeader.MESSAGE_TYPE.withDelimiter() + messageClass);
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
index 0eaafb8..7bc319f 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
@@ -65,6 +65,8 @@
     "DeleteReviewerHtml.soy",
     "DeleteVote.soy",
     "DeleteVoteHtml.soy",
+    "Email.soy",
+    "EmailHtml.soy",
     "InboundEmailRejection.soy",
     "InboundEmailRejectionHtml.soy",
     "Footer.soy",
diff --git a/java/com/google/gerrit/server/mail/send/MergedChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/MergedChangeEmailDecorator.java
new file mode 100644
index 0000000..937d7a8
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/MergedChangeEmailDecorator.java
@@ -0,0 +1,157 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+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.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+import java.util.Optional;
+
+/** Send notice about a change successfully merged. */
+@AutoFactory
+public class MergedChangeEmailDecorator implements ChangeEmailDecorator {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  protected OutgoingEmail email;
+  protected ChangeEmail changeEmail;
+  protected LabelTypes labelTypes;
+  protected final EmailArguments args;
+  protected final Optional<String> stickyApprovalDiff;
+
+  public MergedChangeEmailDecorator(
+      @Provided EmailArguments args, Optional<String> stickyApprovalDiff) {
+    this.args = args;
+    this.stickyApprovalDiff = stickyApprovalDiff;
+  }
+
+  @Override
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+    changeEmail.markAsReply();
+    labelTypes = changeEmail.getChangeData().getLabelTypes();
+
+    // We want to send the submit email even if the "send only when in attention set" is enabled.
+    changeEmail.setEmailOnlyAttentionSetIfEnabled(false);
+
+    NotifyResolver.Result notify = email.getNotify();
+    if (!stickyApprovalDiff.isEmpty() && !notify.handling().equals(NotifyHandling.ALL)) {
+      logger.atFine().log(
+          "Requested to notify %s, but for change submission with sticky approval diff,"
+              + " Notify=ALL is enforced.",
+          notify.handling().name());
+      email.setNotify(NotifyResolver.Result.create(NotifyHandling.ALL, notify.accounts()));
+    }
+  }
+
+  protected String getApprovals() {
+    try {
+      Table<Account.Id, String, PatchSetApproval> pos = HashBasedTable.create();
+      Table<Account.Id, String, PatchSetApproval> neg = HashBasedTable.create();
+      for (PatchSetApproval ca :
+          args.approvalsUtil.byPatchSet(
+              changeEmail.getChangeData().notes(), changeEmail.getPatchSet().id())) {
+        Optional<LabelType> lt = labelTypes.byLabel(ca.labelId());
+        if (!lt.isPresent()) {
+          continue;
+        }
+        if (ca.value() > 0) {
+          pos.put(ca.accountId(), lt.get().getName(), ca);
+        } else if (ca.value() < 0) {
+          neg.put(ca.accountId(), lt.get().getName(), ca);
+        }
+      }
+
+      return format("Approvals", pos) + format("Objections", neg);
+    } catch (StorageException err) {
+      // Don't list the approvals
+    }
+    return "";
+  }
+
+  private String format(String type, Table<Account.Id, String, PatchSetApproval> approvals) {
+    StringBuilder txt = new StringBuilder();
+    if (approvals.isEmpty()) {
+      return "";
+    }
+    txt.append(type).append(":\n");
+    for (Account.Id id : approvals.rowKeySet()) {
+      txt.append("  ");
+      txt.append(email.getNameFor(id));
+      txt.append(": ");
+      boolean first = true;
+      for (LabelType lt : labelTypes.getLabelTypes()) {
+        PatchSetApproval ca = approvals.get(id, lt.getName());
+        if (ca == null) {
+          continue;
+        }
+
+        if (first) {
+          first = false;
+        } else {
+          txt.append("; ");
+        }
+
+        LabelValue v = lt.getValue(ca);
+        if (v != null) {
+          txt.append(v.getText());
+        } else {
+          txt.append(lt.getName());
+          txt.append('=');
+          txt.append(LabelValue.formatValue(ca.value()));
+        }
+      }
+      txt.append('\n');
+    }
+    txt.append('\n');
+    return txt.toString();
+  }
+
+  @Override
+  public void populateEmailContent() throws EmailException {
+    email.addSoyEmailDataParam("approvals", getApprovals());
+    if (stickyApprovalDiff.isPresent()) {
+      email.addSoyEmailDataParam("stickyApprovalDiff", stickyApprovalDiff.get());
+      email.addSoyEmailDataParam(
+          "stickyApprovalDiffHtml", ChangeEmail.getDiffTemplateData(stickyApprovalDiff.get()));
+    }
+
+    changeEmail.addAuthors(RecipientType.TO);
+    changeEmail.ccAllApprovals();
+    changeEmail.bccStarredBy();
+    changeEmail.includeWatchers(NotifyType.ALL_COMMENTS);
+    changeEmail.includeWatchers(NotifyType.SUBMITTED_CHANGES);
+
+    email.appendText(email.textTemplate("Merged"));
+
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("MergedHtml"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedSender.java
deleted file mode 100644
index ce2e3dc..0000000
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ /dev/null
@@ -1,169 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.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;
-import com.google.gerrit.entities.LabelTypes;
-import com.google.gerrit.entities.LabelValue;
-import com.google.gerrit.entities.NotifyConfig.NotifyType;
-import com.google.gerrit.entities.PatchSetApproval;
-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);
-  }
-
-  private final LabelTypes labelTypes;
-  private final Optional<String> stickyApprovalDiff;
-
-  @Inject
-  public MergedSender(
-      EmailArguments args,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id changeId,
-      @Assisted Optional<String> stickyApprovalDiff) {
-    super(args, "merged", newChangeData(args, project, changeId));
-    labelTypes = changeData.getLabelTypes();
-    this.stickyApprovalDiff = stickyApprovalDiff;
-    // We want to send the submit email even if the "send only when in attention set" is enabled.
-    emailOnlyAttentionSetIfEnabled = false;
-  }
-
-  @Override
-  public void setNotify(NotifyResolver.Result notify) {
-    checkNotNull(notify);
-    if (!stickyApprovalDiff.isEmpty()) {
-      if (!notify.handling().equals(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 {
-    super.init();
-
-    ccAllApprovals();
-    bccStarredBy();
-    includeWatchers(NotifyType.ALL_COMMENTS);
-    includeWatchers(NotifyType.SUBMITTED_CHANGES);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("Merged"));
-
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("MergedHtml"));
-    }
-  }
-
-  public String getApprovals() {
-    try {
-      Table<Account.Id, String, PatchSetApproval> pos = HashBasedTable.create();
-      Table<Account.Id, String, PatchSetApproval> neg = HashBasedTable.create();
-      for (PatchSetApproval ca : args.approvalsUtil.byPatchSet(changeData.notes(), patchSet.id())) {
-        Optional<LabelType> lt = labelTypes.byLabel(ca.labelId());
-        if (!lt.isPresent()) {
-          continue;
-        }
-        if (ca.value() > 0) {
-          pos.put(ca.accountId(), lt.get().getName(), ca);
-        } else if (ca.value() < 0) {
-          neg.put(ca.accountId(), lt.get().getName(), ca);
-        }
-      }
-
-      return format("Approvals", pos) + format("Objections", neg);
-    } catch (StorageException err) {
-      // Don't list the approvals
-    }
-    return "";
-  }
-
-  private String format(String type, Table<Account.Id, String, PatchSetApproval> approvals) {
-    StringBuilder txt = new StringBuilder();
-    if (approvals.isEmpty()) {
-      return "";
-    }
-    txt.append(type).append(":\n");
-    for (Account.Id id : approvals.rowKeySet()) {
-      txt.append("  ");
-      txt.append(getNameFor(id));
-      txt.append(": ");
-      boolean first = true;
-      for (LabelType lt : labelTypes.getLabelTypes()) {
-        PatchSetApproval ca = approvals.get(id, lt.getName());
-        if (ca == null) {
-          continue;
-        }
-
-        if (first) {
-          first = false;
-        } else {
-          txt.append("; ");
-        }
-
-        LabelValue v = lt.getValue(ca);
-        if (v != null) {
-          txt.append(v.getText());
-        } else {
-          txt.append(lt.getName());
-          txt.append('=');
-          txt.append(LabelValue.formatValue(ca.value()));
-        }
-      }
-      txt.append('\n');
-    }
-    txt.append('\n');
-    return txt.toString();
-  }
-
-  @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    soyContextEmailData.put("approvals", getApprovals());
-    if (stickyApprovalDiff.isPresent()) {
-      soyContextEmailData.put("stickyApprovalDiff", stickyApprovalDiff.get());
-      soyContextEmailData.put(
-          "stickyApprovalDiffHtml", getDiffTemplateData(stickyApprovalDiff.get()));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java b/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
deleted file mode 100644
index dcf3b6c..0000000
--- a/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Asks a user to review a change. */
-public class ModifyReviewerSender extends NewChangeSender {
-  public interface Factory {
-    ModifyReviewerSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public ModifyReviewerSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, newChangeData(args, project, changeId));
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    ccExistingReviewers();
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
deleted file mode 100644
index 42b018f..0000000
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ /dev/null
@@ -1,134 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.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;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.query.change.ChangeData;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-/** Sends an email alerting a user to a new change for them to review. */
-public abstract class NewChangeSender extends ChangeEmail {
-  private final Set<Account.Id> reviewers = new HashSet<>();
-  private final Set<Address> reviewersByEmail = new HashSet<>();
-  private final Set<Account.Id> extraCC = new HashSet<>();
-  private final Set<Address> extraCCByEmail = new HashSet<>();
-  private final Set<Account.Id> removedReviewers = new HashSet<>();
-  private final Set<Address> removedByEmailReviewers = new HashSet<>();
-
-  protected NewChangeSender(EmailArguments args, ChangeData changeData) {
-    super(args, "newchange", changeData);
-  }
-
-  public void addReviewers(Collection<Account.Id> cc) {
-    reviewers.addAll(cc);
-  }
-
-  public void addReviewersByEmail(Collection<Address> cc) {
-    reviewersByEmail.addAll(cc);
-  }
-
-  public void addExtraCC(Collection<Account.Id> cc) {
-    extraCC.addAll(cc);
-  }
-
-  public void addExtraCCByEmail(Collection<Address> cc) {
-    extraCCByEmail.addAll(cc);
-  }
-
-  public void addRemovedReviewers(Collection<Account.Id> removed) {
-    removedReviewers.addAll(removed);
-  }
-
-  public void addRemovedByEmailReviewers(Collection<Address> removed) {
-    removedByEmailReviewers.addAll(removed);
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    String threadId = getChangeMessageThreadId();
-    setHeader("References", threadId);
-
-    switch (notify.handling()) {
-      case NONE:
-      case OWNER:
-        break;
-      case ALL:
-      default:
-        extraCC.stream().forEach(cc -> addByAccountId(RecipientType.CC, cc));
-        extraCCByEmail.stream().forEach(cc -> addByEmail(RecipientType.CC, cc));
-      // $FALL-THROUGH$
-      case OWNER_REVIEWERS:
-        reviewers.stream().forEach(r -> addByAccountId(RecipientType.TO, r, true));
-        reviewersByEmail.stream().forEach(r -> addByEmail(RecipientType.TO, r, true));
-        removedReviewers.stream().forEach(r -> addByAccountId(RecipientType.TO, r, true));
-        removedByEmailReviewers.stream().forEach(r -> addByEmail(RecipientType.TO, r, true));
-        break;
-    }
-
-    addAuthors(RecipientType.CC);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("NewChange"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("NewChangeHtml"));
-    }
-  }
-
-  @Nullable
-  private List<String> getReviewerNames() {
-    if (reviewers.isEmpty()) {
-      return null;
-    }
-    List<String> names = new ArrayList<>();
-    for (Account.Id id : reviewers) {
-      names.add(getNameFor(id));
-    }
-    return names;
-  }
-
-  @Nullable
-  private List<String> getRemovedReviewerNames() {
-    if (removedReviewers.isEmpty() && removedByEmailReviewers.isEmpty()) {
-      return null;
-    }
-    List<String> names = new ArrayList<>();
-    for (Account.Id id : removedReviewers) {
-      names.add(getNameFor(id));
-    }
-    for (Address address : removedByEmailReviewers) {
-      names.add(address.toString());
-    }
-    return names;
-  }
-
-  @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    soyContext.put("ownerName", getNameFor(change.getOwner()));
-    soyContextEmailData.put("reviewerNames", getReviewerNames());
-    soyContextEmailData.put("removedReviewerNames", getRemovedReviewerNames());
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index aba8f62..4dda7f0 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -18,10 +18,13 @@
 import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.DISABLED;
 import static java.util.Objects.requireNonNull;
 
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.common.base.Throwables;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.EmailHeader;
@@ -39,6 +42,7 @@
 import com.google.gerrit.server.update.RetryableAction.ActionType;
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
+import com.google.template.soy.data.SanitizedContent;
 import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.net.MalformedURLException;
 import java.net.URL;
@@ -57,45 +61,126 @@
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.util.SystemReader;
 
-/** Sends an email to one or more interested parties. */
-public abstract class OutgoingEmail {
+/** Represents an email notification for some event that can be sent to interested parties. */
+@AutoFactory
+public final class OutgoingEmail {
+
+  /** Provides content, recipients and any customizations of the email. */
+  public interface EmailDecorator {
+    /**
+     * Stores the reference to the email for the subsequent calls.
+     *
+     * <p>Both init and populateEmailContent can be called multiply times in case of retries. Init
+     * is therefore responsible for clearing up any changes which are not idempotent and
+     * initializing data for use in populateEmailContent.
+     *
+     * <p>Can be used to adjust any of the behaviour of the {@link
+     * OutgoingEmail#populateEmailContent}.
+     */
+    void init(OutgoingEmail email) throws EmailException;
+
+    /**
+     * Populate headers, recipients and body of the email.
+     *
+     * <p>Method operates on the email provided in the init method.
+     *
+     * <p>By default, all the contents and parameters of the email should be set in this method.
+     */
+    void populateEmailContent() throws EmailException;
+
+    /** If returns false email is not sent to any recipients. */
+    default boolean shouldSendMessage() {
+      return true;
+    }
+
+    /**
+     * Evaluates whether account can be added to the list of recipients.
+     *
+     * @param rcpt the recipient for which it should be checker whether it can be added to the list
+     *     of recipients
+     * @throws PermissionBackendException thrown if checking permissions fails
+     */
+    default boolean isRecipientAllowed(Account.Id rcpt) throws PermissionBackendException {
+      return true;
+    }
+
+    /**
+     * Evaluates whether email can be added to the list of recipients.
+     *
+     * @param rcpt the recipient for which it should be checker whether it can be added to the list
+     *     of recipients
+     * @throws PermissionBackendException thrown if checking permissions fails
+     */
+    default boolean isRecipientAllowed(Address rcpt) throws PermissionBackendException {
+      return true;
+    }
+  }
+
   private static final String SOY_TEMPLATE_NAMESPACE = "com.google.gerrit.server.mail.template";
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  protected String messageClass;
+  private String messageClass;
   private final Set<Account.Id> rcptTo = new HashSet<>();
-  private final Map<String, EmailHeader> headers;
+  private final Map<String, EmailHeader> headers = new LinkedHashMap<>();
   private final Set<Address> smtpRcptTo = new HashSet<>();
   private final Set<Address> smtpBccRcptTo = new HashSet<>();
   private Address smtpFromAddress;
   private StringBuilder textBody;
-  private StringBuilder htmlBody;
+  private ArrayList<SanitizedContent> htmlBodySections;
   private MessageIdGenerator.MessageId messageId;
-  protected Map<String, Object> soyContext;
-  protected Map<String, Object> soyContextEmailData;
-  protected List<String> footers;
-  protected final EmailArguments args;
-  protected Account.Id fromId;
-  protected NotifyResolver.Result notify = NotifyResolver.Result.all();
+  private Map<String, Object> soyContext;
+  private Map<String, Object> soyContextEmailData;
+  private List<String> footers;
+  private final EmailArguments args;
+  private Account.Id fromId;
+  private NotifyResolver.Result notify = NotifyResolver.Result.all();
+  private final EmailDecorator templateProvider;
+  private ArrayList<EmailResource> htmlResources;
 
-  protected OutgoingEmail(EmailArguments args, String messageClass) {
+  public OutgoingEmail(
+      @Provided EmailArguments args, String messageClass, EmailDecorator templateProvider) {
     this.args = args;
     this.messageClass = messageClass;
-    this.headers = new LinkedHashMap<>();
+    this.templateProvider = templateProvider;
   }
 
+  /** Specify the account that triggered the notification. */
   public void setFrom(Account.Id id) {
     fromId = id;
   }
 
+  /** Get the account that triggered the notification. */
+  public Account.Id getFrom() {
+    return fromId;
+  }
+
+  /** Set how widely the email notification is allowed to be sent. */
   public void setNotify(NotifyResolver.Result notify) {
     this.notify = requireNonNull(notify);
   }
 
+  /** Returns the setting that controls how widely the email notification is allowed to be sent. */
+  public NotifyResolver.Result getNotify() {
+    return this.notify;
+  }
+
+  /** Set identifier for the email. Every email must have one. */
   public void setMessageId(MessageIdGenerator.MessageId messageId) {
     this.messageId = messageId;
   }
 
+  private String constructTextEmail() {
+    soyContext.put("body", textBody.toString());
+    soyContext.put("footer", textTemplate("Footer"));
+    return textTemplate("Email");
+  }
+
+  private String constructHtmlEmail() {
+    soyContext.put("body_sections_html", htmlBodySections);
+    soyContext.put("footer_html", soyHtmlTemplate("FooterHtml"));
+    return soyHtmlTemplate("EmailHtml").toString();
+  }
+
   /** Format and enqueue the message for delivery. */
   public void send() throws EmailException {
     try {
@@ -125,20 +210,15 @@
       return;
     }
 
+    init();
     if (!notify.shouldNotify()) {
       logger.atFine().log("Not sending '%s': Notify handling is NONE", messageClass);
       return;
     }
-
-    init();
+    populateEmailContent();
     if (messageId == null) {
       throw new IllegalStateException("All emails must have a messageId");
     }
-    format();
-    appendText(textTemplate("Footer"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("FooterHtml"));
-    }
 
     Set<Address> smtpRcptToPlaintextOnly = new HashSet<>();
     if (shouldSendMessage()) {
@@ -238,16 +318,15 @@
         setHeader(FieldName.REPLY_TO, j.toString());
       }
 
-      String textPart = textBody.toString();
       OutgoingEmailValidationListener.Args va = new OutgoingEmailValidationListener.Args();
       va.messageClass = messageClass;
       va.smtpFromAddress = smtpFromAddress;
       va.smtpRcptTo = smtpRcptTo;
       va.headers = headers;
-      va.body = textPart;
+      va.body = constructTextEmail();
 
       if (useHtml()) {
-        va.htmlBody = htmlBody.toString();
+        va.htmlBody = constructHtmlEmail();
       } else {
         va.htmlBody = null;
       }
@@ -263,7 +342,8 @@
         logger.atFine().log(
             "Sending multipart '%s' from %s to %s",
             messageClass, va.smtpFromAddress, va.smtpRcptTo);
-        args.emailSender.send(va.smtpFromAddress, va.smtpRcptTo, va.headers, va.body, va.htmlBody);
+        args.emailSender.send(
+            va.smtpFromAddress, va.smtpRcptTo, va.headers, va.body, va.htmlBody, htmlResources);
       }
       if (!smtpRcptToPlaintextOnly.isEmpty()) {
         addMessageId(va, "-PLAIN");
@@ -275,7 +355,7 @@
         shallowCopy.remove(FieldName.CC);
         for (Address a : smtpRcptToPlaintextOnly) {
           // Add new To
-          EmailHeader.AddressList to = new EmailHeader.AddressList();
+          AddressList to = new AddressList();
           to.add(a);
           shallowCopy.put(FieldName.TO, to);
         }
@@ -311,39 +391,37 @@
     }
   }
 
-  /** Format the message body by calling {@link #appendText(String)}. */
-  protected abstract void format() throws EmailException;
-
   /**
    * Setup the message headers and envelope (TO, CC, BCC).
    *
    * @throws EmailException if an error occurred.
    */
-  protected void init() throws EmailException {
-    setupSoyContext();
+  public void init() throws EmailException {
+    soyContext = new HashMap<>();
+    footers = new ArrayList<>();
+    soyContextEmailData = new HashMap<>();
+    htmlResources = new ArrayList<>();
 
     smtpFromAddress = args.fromAddressGenerator.get().from(fromId);
     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());
+    headers.put(FieldName.FROM, new AddressList(smtpFromAddress));
+    headers.put(FieldName.TO, new AddressList());
+    headers.put(FieldName.CC, new AddressList());
     setHeader(MailHeader.AUTO_SUBMITTED.fieldName(), "auto-generated");
 
-    for (RecipientType recipientType : notify.accounts().keySet()) {
-      notify.accounts().get(recipientType).stream().forEach(a -> addByAccountId(recipientType, a));
-    }
-
     setHeader(MailHeader.MESSAGE_TYPE.fieldName(), messageClass);
-    footers.add(MailHeader.MESSAGE_TYPE.withDelimiter() + messageClass);
+    addFooter(MailHeader.MESSAGE_TYPE.withDelimiter() + messageClass);
     textBody = new StringBuilder();
-    htmlBody = new StringBuilder();
+    htmlBodySections = new ArrayList<>();
 
     if (fromId != null && args.fromAddressGenerator.get().isGenericAddress(fromId)) {
       appendText(getFromLine());
     }
+
+    templateProvider.init(this);
   }
 
-  protected String getFromLine() {
+  private String getFromLine() {
     StringBuilder f = new StringBuilder();
     Optional<Account> account = args.accountCache.get(fromId).map(AccountState::account);
     if (account.isPresent()) {
@@ -364,9 +442,10 @@
   }
 
   public String getGerritHost() {
-    if (getGerritUrl() != null) {
+    Optional<String> gerritUrl = args.urlFormatter.get().getWebUrl();
+    if (gerritUrl.isPresent()) {
       try {
-        return new URL(getGerritUrl()).getHost();
+        return new URL(gerritUrl.get()).getHost();
       } catch (MalformedURLException e) {
         // Try something else.
       }
@@ -381,44 +460,49 @@
 
   @Nullable
   public String getSettingsUrl() {
-    return args.urlFormatter.get().getSettingsUrl().orElse(null);
+    return args.urlFormatter.get().getSettingsUrl().map(EmailArguments::addUspParam).orElse(null);
   }
 
   @Nullable
-  private String getGerritUrl() {
-    return args.urlFormatter.get().getWebUrl().orElse(null);
+  public String getSettingsUrl(String section) {
+    return args.urlFormatter
+        .get()
+        .getSettingsUrl(section)
+        .map(EmailArguments::addUspParam)
+        .orElse(null);
   }
 
   /** Set a header in the outgoing message. */
-  protected void setHeader(String name, String value) {
+  public void setHeader(String name, String value) {
     headers.put(name, new StringEmailHeader(value));
   }
 
   /** Remove a header from the outgoing message. */
-  protected void removeHeader(String name) {
+  public void removeHeader(String name) {
     headers.remove(name);
   }
 
-  protected void setHeader(String name, Instant date) {
+  /** Set a date header in the outgoing message. */
+  public void setHeader(String name, Instant date) {
     headers.put(name, new EmailHeader.Date(date));
   }
 
   /** Append text to the outgoing email body. */
-  protected void appendText(String text) {
+  public void appendText(String text) {
     if (text != null) {
       textBody.append(text);
     }
   }
 
   /** Append html to the outgoing email body. */
-  protected void appendHtml(String html) {
+  public void appendHtml(SanitizedContent html) {
     if (html != null) {
-      htmlBody.append(html);
+      htmlBodySections.add(html);
     }
   }
 
   /** Lookup a human readable name for an account, usually the "full name". */
-  protected String getNameFor(@Nullable Account.Id accountId) {
+  public String getNameFor(@Nullable Account.Id accountId) {
     if (accountId == null) {
       return args.gerritPersonIdent.get().getName();
     }
@@ -444,7 +528,7 @@
    * @param accountId user to fetch.
    * @return name/email of account, or Anonymous Coward if unset.
    */
-  protected String getNameEmailFor(@Nullable Account.Id accountId) {
+  public String getNameEmailFor(@Nullable Account.Id accountId) {
     if (accountId == null) {
       PersonIdent gerritIdent = args.gerritPersonIdent.get();
       return gerritIdent.getName() + " <" + gerritIdent.getEmailAddress() + ">";
@@ -473,7 +557,7 @@
    * @return name/email of account, username, or null if unset or the accountId is null.
    */
   @Nullable
-  protected String getUserNameEmailFor(@Nullable Account.Id accountId) {
+  public String getUserNameEmailFor(@Nullable Account.Id accountId) {
     if (accountId == null) {
       return null;
     }
@@ -496,7 +580,7 @@
     return accountState.get().userName().orElse(null);
   }
 
-  protected boolean shouldSendMessage() {
+  private boolean shouldSendMessage() {
     if (textBody.length() == 0) {
       // If we have no message body, don't send.
       logger.atFine().log("Not sending '%s': No message body", messageClass);
@@ -521,7 +605,7 @@
       return false;
     }
 
-    return true;
+    return templateProvider.shouldSendMessage();
   }
 
   /**
@@ -559,8 +643,8 @@
    * @throws PermissionBackendException thrown if checking a permission fails due to an error in the
    *     permission backend
    */
-  protected boolean isRecipientAllowed(Address addr) throws PermissionBackendException {
-    return true;
+  public boolean isRecipientAllowed(Address addr) throws PermissionBackendException {
+    return templateProvider.isRecipientAllowed(addr);
   }
 
   /**
@@ -569,7 +653,7 @@
    * @param rt category of recipient (TO, CC, BCC)
    * @param to Gerrit Account of the recipient.
    */
-  protected void addByAccountId(RecipientType rt, Account.Id to) {
+  public void addByAccountId(RecipientType rt, Account.Id to) {
     addByAccountId(rt, to, false);
   }
 
@@ -581,12 +665,18 @@
    * @param override if the recipient was added previously and override is false no change is made
    *     regardless of {@code rt}.
    */
-  protected void addByAccountId(RecipientType rt, Account.Id to, boolean override) {
+  public void addByAccountId(RecipientType rt, Account.Id to, boolean override) {
     try {
-      if (!rcptTo.contains(to) && isRecipientAllowed(to)) {
-        rcptTo.add(to);
-        add(rt, toAddress(to), override);
+      if (rcptTo.contains(to) || !isRecipientAllowed(to)) {
+        return;
       }
+      Address addr = toAddress(to);
+      if (addr == null) {
+        logger.atFine().log("Not emailing account %s because user has no preferred email", to);
+        return;
+      }
+      rcptTo.add(to);
+      add(rt, addr, override);
     } catch (PermissionBackendException e) {
       logger.atSevere().withCause(e).log("Error checking permissions for account: %s", to);
     }
@@ -599,8 +689,8 @@
    * @throws PermissionBackendException thrown if checking a permission fails due to an error in the
    *     permission backend
    */
-  protected boolean isRecipientAllowed(Account.Id to) throws PermissionBackendException {
-    return true;
+  public boolean isRecipientAllowed(Account.Id to) throws PermissionBackendException {
+    return templateProvider.isRecipientAllowed(to);
   }
 
   private final void add(RecipientType rt, Address addr, boolean override) {
@@ -612,16 +702,16 @@
           if (!override) {
             return;
           }
-          ((EmailHeader.AddressList) headers.get(FieldName.TO)).remove(addr.email());
-          ((EmailHeader.AddressList) headers.get(FieldName.CC)).remove(addr.email());
+          ((AddressList) headers.get(FieldName.TO)).remove(addr.email());
+          ((AddressList) headers.get(FieldName.CC)).remove(addr.email());
           smtpBccRcptTo.remove(addr);
         }
         switch (rt) {
           case TO:
-            ((EmailHeader.AddressList) headers.get(FieldName.TO)).add(addr);
+            ((AddressList) headers.get(FieldName.TO)).add(addr);
             break;
           case CC:
-            ((EmailHeader.AddressList) headers.get(FieldName.CC)).add(addr);
+            ((AddressList) headers.get(FieldName.CC)).add(addr);
             break;
           case BCC:
             smtpBccRcptTo.add(addr);
@@ -631,8 +721,9 @@
     }
   }
 
+  /** Returns preferred email address for the account. */
   @Nullable
-  private Address toAddress(Account.Id id) {
+  public Address toAddress(Account.Id id) {
     Optional<Account> accountState = args.accountCache.get(id).map(AccountState::account);
     if (!accountState.isPresent()) {
       return null;
@@ -646,31 +737,35 @@
     return Address.create(account.fullName(), e);
   }
 
-  protected void setupSoyContext() {
-    soyContext = new HashMap<>();
-    footers = new ArrayList<>();
-
-    soyContext.put("messageClass", messageClass);
-    soyContext.put("footers", footers);
-
-    soyContextEmailData = new HashMap<>();
-    soyContextEmailData.put("settingsUrl", getSettingsUrl());
-    soyContextEmailData.put("instanceName", getInstanceName());
-    soyContextEmailData.put("gerritHost", getGerritHost());
-    soyContextEmailData.put("gerritUrl", getGerritUrl());
-    soyContext.put("email", soyContextEmailData);
+  /** Returns the type of notification being sent. */
+  public String getMessageClass() {
+    return messageClass;
   }
 
-  /** Mutable map of parameters passed into email templates when rendering. */
-  public Map<String, Object> getSoyContext() {
-    return this.soyContext;
+  /** Set recipients, headers, body of the email. */
+  public void populateEmailContent() throws EmailException {
+    for (RecipientType recipientType : notify.accounts().keySet()) {
+      notify.accounts().get(recipientType).stream().forEach(a -> addByAccountId(recipientType, a));
+    }
+
+    addSoyParam("messageClass", messageClass);
+    addSoyParam("footers", footers);
+    addSoyEmailDataParam("settingsUrl", getSettingsUrl());
+    addSoyEmailDataParam("instanceName", getInstanceName());
+    addSoyEmailDataParam("gerritHost", getGerritHost());
+    addSoyParam("email", soyContextEmailData);
+
+    templateProvider.populateEmailContent();
   }
 
-  // TODO: It's not clear why we need this explicit separation. Probably worth
-  // simplifying.
-  /** Mutable content of `email` parameter in the templates. */
-  public Map<String, Object> getSoyContextEmailData() {
-    return this.soyContextEmailData;
+  /** Adds param to the data map passed into soy when rendering templates. */
+  public void addSoyParam(String key, Object value) {
+    soyContext.put(key, value);
+  }
+
+  /** Adds entry to the `email` param passed to the soy when rendering templates. */
+  public void addSoyEmailDataParam(String key, Object value) {
+    soyContextEmailData.put(key, value);
   }
 
   /**
@@ -681,18 +776,31 @@
     footers.add(footer);
   }
 
+  /**
+   * Add a resource that can be referenced in HTML code using their {@link EmailResource#contentId}.
+   */
+  public void addHtmlResource(EmailResource resource) {
+    htmlResources.add(resource);
+  }
+
   private String getInstanceName() {
     return args.instanceNameProvider.get();
   }
 
   /** Renders a soy template of kind="text". */
-  protected String textTemplate(String name) {
+  public String textTemplate(String name) {
     return configureRenderer(name).renderText().get();
   }
 
   /** Renders a soy template of kind="html". */
-  protected String soyHtmlTemplate(String name) {
-    return configureRenderer(name).renderHtml().get().toString();
+  public SanitizedContent soyHtmlTemplate(String name) {
+    return configureRenderer(name).renderHtml().get();
+  }
+
+  /** Renders a soy template of kind="css". */
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public SanitizedContent soyCssTemplate(String name) {
+    return configureRenderer(name).renderCss().get();
   }
 
   /** Configures a soy renderer for the given template name and rendering data map. */
@@ -715,7 +823,8 @@
     return soySauce.renderTemplate(fullTemplateName).setData(soyContext);
   }
 
-  protected void removeUser(Account user) {
+  /** Remove user from the multipart email recipients. */
+  private void removeUser(Account user) {
     String fromEmail = user.preferredEmail();
     for (Iterator<Address> j = smtpRcptTo.iterator(); j.hasNext(); ) {
       if (j.next().email().equals(fromEmail)) {
@@ -730,7 +839,8 @@
     }
   }
 
-  protected final boolean useHtml() {
+  /** Return true, if the email should include html body. */
+  public boolean useHtml() {
     return args.settings.html;
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index 82dc7d02..75159b0 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -116,9 +116,9 @@
   }
 
   public static class Watchers {
-    static class WatcherList {
-      protected final Set<Account.Id> accounts = new HashSet<>();
-      protected final Set<Address> emails = new HashSet<>();
+    public static class WatcherList {
+      public final Set<Account.Id> accounts = new HashSet<>();
+      public final Set<Address> emails = new HashSet<>();
 
       private static WatcherList union(WatcherList... others) {
         WatcherList union = new WatcherList();
@@ -130,15 +130,15 @@
       }
     }
 
-    protected final WatcherList to = new WatcherList();
-    protected final WatcherList cc = new WatcherList();
-    protected final WatcherList bcc = new WatcherList();
+    public final WatcherList to = new WatcherList();
+    public final WatcherList cc = new WatcherList();
+    public final WatcherList bcc = new WatcherList();
 
-    WatcherList all() {
+    public WatcherList all() {
       return WatcherList.union(to, cc, bcc);
     }
 
-    WatcherList list(NotifyConfig.Header header) {
+    public WatcherList list(NotifyConfig.Header header) {
       switch (header) {
         case TO:
           return to;
diff --git a/java/com/google/gerrit/server/mail/send/RegisterNewEmailDecorator.java b/java/com/google/gerrit/server/mail/send/RegisterNewEmailDecorator.java
new file mode 100644
index 0000000..ac17d9b
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/RegisterNewEmailDecorator.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+
+/**
+ * Sender that informs a user by email about the registration of a new email address for their
+ * account.
+ */
+public interface RegisterNewEmailDecorator extends EmailDecorator {
+  /** Can the email be sent to the newly added address. */
+  boolean isAllowed();
+}
diff --git a/java/com/google/gerrit/server/mail/send/RegisterNewEmailDecoratorImpl.java b/java/com/google/gerrit/server/mail/send/RegisterNewEmailDecoratorImpl.java
new file mode 100644
index 0000000..02ea32b
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/RegisterNewEmailDecoratorImpl.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.mail.EmailTokenVerifier;
+
+/**
+ * Sender that informs a user by email about the registration of a new email address for their
+ * account.
+ */
+@AutoFactory
+public class RegisterNewEmailDecoratorImpl implements RegisterNewEmailDecorator {
+  private OutgoingEmail email;
+  private final EmailArguments args;
+  private final EmailTokenVerifier tokenVerifier;
+  private final IdentifiedUser user;
+  private final String addr;
+  private String emailToken;
+
+  RegisterNewEmailDecoratorImpl(
+      @Provided EmailArguments args,
+      @Provided EmailTokenVerifier tokenVerifier,
+      @Provided IdentifiedUser callingUser,
+      final String address) {
+    this.args = args;
+    this.tokenVerifier = tokenVerifier;
+    this.user = callingUser;
+    this.addr = address;
+  }
+
+  @Override
+  public void init(OutgoingEmail email) {
+    this.email = email;
+
+    email.setHeader("Subject", "[Gerrit Code Review] Email Verification");
+    email.addByEmail(RecipientType.TO, Address.create(addr));
+  }
+
+  @Override
+  public boolean isAllowed() {
+    return args.emailSender.canEmail(addr);
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyEmailDataParam("userNameEmail", email.getUserNameEmailFor(user.getAccountId()));
+    email.addSoyEmailDataParam("emailRegistrationLink", getEmailRegistrationLink());
+
+    email.appendText(email.textTemplate("RegisterNewEmail"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("RegisterNewEmailHtml"));
+    }
+  }
+
+  private String getEmailRegistrationLink() {
+    if (emailToken == null) {
+      emailToken = requireNonNull(tokenVerifier.encode(user.getAccountId(), addr), "token");
+    }
+    return args.urlFormatter.get().getWebUrl().orElse("") + "#/VE/" + emailToken;
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java b/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
deleted file mode 100644
index f7bc336..0000000
--- a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
+++ /dev/null
@@ -1,85 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import static java.util.Objects.requireNonNull;
-
-import com.google.gerrit.entities.Address;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.mail.EmailTokenVerifier;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/**
- * Sender that informs a user by email about the registration of a new email address for their
- * account.
- */
-public class RegisterNewEmailSender extends OutgoingEmail {
-  public interface Factory {
-    RegisterNewEmailSender create(String address);
-  }
-
-  private final EmailTokenVerifier tokenVerifier;
-  private final IdentifiedUser user;
-  private final String addr;
-  private String emailToken;
-
-  @Inject
-  public RegisterNewEmailSender(
-      EmailArguments args,
-      EmailTokenVerifier tokenVerifier,
-      IdentifiedUser callingUser,
-      @Assisted final String address) {
-    super(args, "registernewemail");
-    this.tokenVerifier = tokenVerifier;
-    this.user = callingUser;
-    this.addr = address;
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setHeader("Subject", "[Gerrit Code Review] Email Verification");
-    addByEmail(RecipientType.TO, Address.create(addr));
-  }
-
-  @Override
-  protected void format() throws EmailException {
-    appendText(textTemplate("RegisterNewEmail"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("RegisterNewEmailHtml"));
-    }
-  }
-
-  public boolean isAllowed() {
-    return args.emailSender.canEmail(addr);
-  }
-
-  @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    soyContextEmailData.put("emailRegistrationToken", getEmailRegistrationToken());
-    soyContextEmailData.put("userNameEmail", getUserNameEmailFor(user.getAccountId()));
-  }
-
-  private String getEmailRegistrationToken() {
-    if (emailToken == null) {
-      emailToken = requireNonNull(tokenVerifier.encode(user.getAccountId(), addr), "token");
-    }
-    return emailToken;
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java b/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
deleted file mode 100644
index 5242bfb..0000000
--- a/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
+++ /dev/null
@@ -1,43 +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.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Let users know of a user removed from the attention set. */
-public class RemoveFromAttentionSetSender extends AttentionSetSender {
-
-  public interface Factory extends ReplyToChangeSender.Factory<RemoveFromAttentionSetSender> {
-    @Override
-    RemoveFromAttentionSetSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public RemoveFromAttentionSetSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, "removeFromAttentionSet", project, changeId);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("RemoveFromAttentionSet"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("RemoveFromAttentionSetHtml"));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/ReplacePatchSetChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/ReplacePatchSetChangeEmailDecorator.java
new file mode 100644
index 0000000..a798a9e
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/ReplacePatchSetChangeEmailDecorator.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+import java.util.Collection;
+
+/** Send notice of new patch sets for reviewers. */
+public interface ReplacePatchSetChangeEmailDecorator extends ChangeEmailDecorator {
+  /** Add reviewers that should be notified. */
+  void addReviewers(Collection<Account.Id> cc);
+
+  /** Add non-reviewer targets to be notified. */
+  void addExtraCC(Collection<Account.Id> cc);
+
+  /** Provide set of approvals that are now outdated. */
+  void addOutdatedApproval(@Nullable Collection<PatchSetApproval> outdatedApprovals);
+}
diff --git a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/java/com/google/gerrit/server/mail/send/ReplacePatchSetChangeEmailDecoratorImpl.java
similarity index 65%
rename from java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
rename to java/com/google/gerrit/server/mail/send/ReplacePatchSetChangeEmailDecoratorImpl.java
index 188c5d8..08b841b 100644
--- a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/ReplacePatchSetChangeEmailDecoratorImpl.java
@@ -16,6 +16,8 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.common.base.Supplier;
 import com.google.common.base.Suppliers;
 import com.google.common.collect.ImmutableList;
@@ -28,13 +30,10 @@
 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;
@@ -44,36 +43,31 @@
 import org.eclipse.jgit.lib.ObjectId;
 
 /** Send notice of new patch sets for reviewers. */
-public class ReplacePatchSetSender extends ReplyToChangeSender {
+@AutoFactory
+public class ReplacePatchSetChangeEmailDecoratorImpl
+    implements ReplacePatchSetChangeEmailDecorator {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public interface Factory {
-    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>>
+  protected final EmailArguments args;
+  protected OutgoingEmail email;
+  protected ChangeEmail changeEmail;
+  protected final Set<Account.Id> reviewers = new HashSet<>();
+  protected final Set<Account.Id> extraCC = new HashSet<>();
+  protected final ChangeKind changeKind;
+  protected final Set<PatchSetApproval> outdatedApprovals = new HashSet<>();
+  protected final Supplier<Map<SubmitRequirement, SubmitRequirementResult>>
       preUpdateSubmitRequirementResultsSupplier;
-  private final Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults;
+  protected final Map<SubmitRequirement, SubmitRequirementResult>
+      postUpdateSubmitRequirementResults;
 
-  @Inject
-  public ReplacePatchSetSender(
-      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));
+  public ReplacePatchSetChangeEmailDecoratorImpl(
+      @Provided EmailArguments args,
+      Project.NameKey project,
+      Change.Id changeId,
+      ChangeKind changeKind,
+      ObjectId preUpdateMetaId,
+      Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
+    this.args = args;
     this.changeKind = changeKind;
 
     this.preUpdateSubmitRequirementResultsSupplier =
@@ -81,32 +75,34 @@
             () ->
                 // 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)
+                args.newChangeData(project, changeId, preUpdateMetaId)
                     .submitRequirementsIncludingLegacy());
 
     this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
   }
 
   @Override
-  protected boolean shouldSendMessage() {
+  public 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();
+    return true;
   }
 
+  @Override
   public void addReviewers(Collection<Account.Id> cc) {
     reviewers.addAll(cc);
   }
 
+  @Override
   public void addExtraCC(Collection<Account.Id> cc) {
     extraCC.addAll(cc);
   }
 
+  @Override
   public void addOutdatedApproval(@Nullable Collection<PatchSetApproval> outdatedApprovals) {
     if (outdatedApprovals != null) {
       this.outdatedApprovals.addAll(outdatedApprovals);
@@ -114,42 +110,27 @@
   }
 
   @Override
-  protected void init() throws EmailException {
-    super.init();
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+    changeEmail.markAsReply();
 
+    Account.Id fromId = email.getFrom();
     if (fromId != null) {
       // Don't call yourself a reviewer of your own patch set.
       //
       reviewers.remove(fromId);
     }
-    if (args.settings.sendNewPatchsetEmails) {
-      if (notify.handling().equals(NotifyHandling.ALL)
-          || notify.handling().equals(NotifyHandling.OWNER_REVIEWERS)) {
-        reviewers.stream().forEach(r -> addByAccountId(RecipientType.TO, r));
-        extraCC.stream().forEach(cc -> addByAccountId(RecipientType.CC, cc));
-      }
-      addAuthors(RecipientType.CC);
-    }
-    bccStarredBy();
-    includeWatchers(NotifyType.NEW_PATCHSETS, !change.isWorkInProgress() && !change.isPrivate());
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("ReplacePatchSet"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("ReplacePatchSetHtml"));
-    }
   }
 
   @Nullable
-  public ImmutableList<String> getReviewerNames() {
+  protected ImmutableList<String> getReviewerNames() {
     List<String> names = new ArrayList<>();
     for (Account.Id id : reviewers) {
-      if (id.equals(fromId)) {
+      if (id.equals(email.getFrom())) {
         continue;
       }
-      names.add(getNameFor(id));
+      names.add(email.getNameFor(id));
     }
     if (names.isEmpty()) {
       return null;
@@ -157,31 +138,49 @@
     return names.stream().sorted().collect(toImmutableList());
   }
 
-  private ImmutableList<String> formatOutdatedApprovals() {
+  protected ImmutableList<String> formatOutdatedApprovals() {
     return outdatedApprovals.stream()
         .map(
             outdatedApproval ->
                 String.format(
                     "%s by %s",
                     LabelVote.create(outdatedApproval.label(), outdatedApproval.value()).format(),
-                    getNameFor(outdatedApproval.accountId())))
+                    email.getNameFor(outdatedApproval.accountId())))
         .sorted()
         .collect(toImmutableList());
   }
 
   @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    soyContextEmailData.put("reviewerNames", getReviewerNames());
-    soyContextEmailData.put("outdatedApprovals", formatOutdatedApprovals());
+  public void populateEmailContent() {
+    changeEmail.addAuthors(RecipientType.TO);
+
+    email.addSoyEmailDataParam("reviewerNames", getReviewerNames());
+    email.addSoyEmailDataParam("outdatedApprovals", formatOutdatedApprovals());
 
     if (isChangeNoLongerSubmittable()) {
-      soyContext.put("unsatisfiedSubmitRequirements", formatUnsatisfiedSubmitRequirements());
-      soyContext.put(
+      email.addSoyParam("unsatisfiedSubmitRequirements", formatUnsatisfiedSubmitRequirements());
+      email.addSoyParam(
           "oldSubmitRequirements",
-          formatSubmitRequirments(preUpdateSubmitRequirementResultsSupplier.get()));
-      soyContext.put(
-          "newSubmitRequirements", formatSubmitRequirments(postUpdateSubmitRequirementResults));
+          formatSubmitRequirements(preUpdateSubmitRequirementResultsSupplier.get()));
+      email.addSoyParam(
+          "newSubmitRequirements", formatSubmitRequirements(postUpdateSubmitRequirementResults));
+    }
+
+    if (args.settings.sendNewPatchsetEmails) {
+      if (email.getNotify().handling().equals(NotifyHandling.ALL)
+          || email.getNotify().handling().equals(NotifyHandling.OWNER_REVIEWERS)) {
+        reviewers.stream().forEach(r -> email.addByAccountId(RecipientType.TO, r));
+        extraCC.stream().forEach(cc -> email.addByAccountId(RecipientType.CC, cc));
+      }
+    }
+    changeEmail.bccStarredBy();
+    changeEmail.includeWatchers(
+        NotifyType.NEW_PATCHSETS,
+        !changeEmail.getChange().isWorkInProgress() && !changeEmail.getChange().isPrivate());
+
+    email.appendText(email.textTemplate("ReplacePatchSet"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("ReplacePatchSetHtml"));
     }
   }
 
@@ -197,7 +196,7 @@
             .allMatch(SubmitRequirementResult::fulfilled);
     logger.atFine().log(
         "the submitability of change %s before the update is %s",
-        change.getId(), isSubmittablePreUpdate);
+        changeEmail.getChange().getId(), isSubmittablePreUpdate);
     if (!isSubmittablePreUpdate) {
       return false;
     }
@@ -207,7 +206,7 @@
             .allMatch(SubmitRequirementResult::fulfilled);
     logger.atFine().log(
         "the submitability of change %s after the update is %s",
-        change.getId(), isSubmittablePostUpdate);
+        changeEmail.getChange().getId(), isSubmittablePostUpdate);
     return !isSubmittablePostUpdate;
   }
 
@@ -220,7 +219,7 @@
         .collect(toImmutableList());
   }
 
-  private static ImmutableList<String> formatSubmitRequirments(
+  private static ImmutableList<String> formatSubmitRequirements(
       Map<SubmitRequirement, SubmitRequirementResult> submitRequirementResults) {
     return submitRequirementResults.entrySet().stream()
         .map(
diff --git a/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java b/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
deleted file mode 100644
index 696cd17..0000000
--- a/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.query.change.ChangeData;
-
-/** Alert a user to a reply to a change, usually commentary made during review. */
-public abstract class ReplyToChangeSender extends ChangeEmail {
-  public interface Factory<T extends ReplyToChangeSender> {
-    T create(Project.NameKey project, Change.Id id);
-  }
-
-  protected ReplyToChangeSender(EmailArguments args, String messageClass, ChangeData changeData) {
-    super(args, messageClass, changeData);
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    final String threadId = getChangeMessageThreadId();
-    setHeader("In-Reply-To", threadId);
-    setHeader("References", threadId);
-
-    addAuthors(RecipientType.TO);
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/RestoredChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/RestoredChangeEmailDecorator.java
new file mode 100644
index 0000000..fea7ecd
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/RestoredChangeEmailDecorator.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2011 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.NotifyConfig.NotifyType;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+
+/** Send notice about a change being restored by its owner. */
+public class RestoredChangeEmailDecorator implements ChangeEmailDecorator {
+  protected OutgoingEmail email;
+  protected ChangeEmail changeEmail;
+
+  @Override
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+    changeEmail.markAsReply();
+  }
+
+  @Override
+  public void populateEmailContent() {
+    changeEmail.addAuthors(RecipientType.TO);
+
+    changeEmail.ccAllApprovals();
+    changeEmail.bccStarredBy();
+    changeEmail.includeWatchers(NotifyType.ALL_COMMENTS);
+
+    email.appendText(email.textTemplate("Restored"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("RestoredHtml"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/RestoredSender.java b/java/com/google/gerrit/server/mail/send/RestoredSender.java
deleted file mode 100644
index e37d8f9..0000000
--- a/java/com/google/gerrit/server/mail/send/RestoredSender.java
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.Change;
-import com.google.gerrit.entities.NotifyConfig.NotifyType;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Send notice about a change being restored by its owner. */
-public class RestoredSender extends ReplyToChangeSender {
-  public interface Factory extends ReplyToChangeSender.Factory<RestoredSender> {
-    @Override
-    RestoredSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public RestoredSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, "restore", ChangeEmail.newChangeData(args, project, changeId));
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    ccAllApprovals();
-    bccStarredBy();
-    includeWatchers(NotifyType.ALL_COMMENTS);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("Restored"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("RestoredHtml"));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/RevertedChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/RevertedChangeEmailDecorator.java
new file mode 100644
index 0000000..4328843
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/RevertedChangeEmailDecorator.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2011 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.NotifyConfig.NotifyType;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+
+/** Send notice about a change being reverted. */
+public class RevertedChangeEmailDecorator implements ChangeEmailDecorator {
+  protected OutgoingEmail email;
+  protected ChangeEmail changeEmail;
+
+  @Override
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+    changeEmail.markAsReply();
+  }
+
+  @Override
+  public void populateEmailContent() {
+    changeEmail.addAuthors(RecipientType.TO);
+
+    changeEmail.ccAllApprovals();
+    changeEmail.bccStarredBy();
+    changeEmail.includeWatchers(NotifyType.ALL_COMMENTS);
+
+    email.appendText(email.textTemplate("Reverted"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("RevertedHtml"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/RevertedSender.java b/java/com/google/gerrit/server/mail/send/RevertedSender.java
deleted file mode 100644
index 1d7223d..0000000
--- a/java/com/google/gerrit/server/mail/send/RevertedSender.java
+++ /dev/null
@@ -1,52 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY 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.Change;
-import com.google.gerrit.entities.NotifyConfig.NotifyType;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Send notice about a change being reverted. */
-public class RevertedSender extends ReplyToChangeSender {
-  public interface Factory {
-    RevertedSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public RevertedSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, "revert", ChangeEmail.newChangeData(args, project, changeId));
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    ccAllApprovals();
-    bccStarredBy();
-    includeWatchers(NotifyType.ALL_COMMENTS);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("Reverted"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("RevertedHtml"));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
index 14b3035..17a59a4 100644
--- a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
@@ -53,7 +53,11 @@
 import org.apache.james.mime4j.codec.QuotedPrintableOutputStream;
 import org.eclipse.jgit.lib.Config;
 
-/** Sends email via a nearby SMTP server. */
+/**
+ * Sends email via a nearby SMTP server.
+ *
+ * <p>Doesn't support including EmailResource in the payload.
+ */
 @Singleton
 public class SmtpEmailSender implements EmailSender {
   /** The socket's connect timeout (0 = infinite timeout) */
diff --git a/java/com/google/gerrit/server/mail/send/StartReviewChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/StartReviewChangeEmailDecorator.java
new file mode 100644
index 0000000..481e5a2
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/StartReviewChangeEmailDecorator.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+import java.util.Collection;
+
+/** Sends an email alerting a user to a new change for them to review. */
+public interface StartReviewChangeEmailDecorator extends ChangeEmailDecorator {
+
+  /** Add initial set of reviewers. */
+  void addReviewers(Collection<Account.Id> cc);
+
+  /** Add initial set of reviewers by email (non-account). */
+  void addReviewersByEmail(Collection<Address> cc);
+
+  /** Add initial set of cc-ed accounts. */
+  void addExtraCC(Collection<Account.Id> cc);
+
+  /** Add initial set of cc-ed emails. */
+  void addExtraCCByEmail(Collection<Address> cc);
+
+  /** Set of reviewers that are removed when sending for review. */
+  void addRemovedReviewers(Collection<Account.Id> removed);
+
+  /** Set of reviewers by email (non-account) that are removed when sending for review. */
+  void addRemovedByEmailReviewers(Collection<Address> removed);
+
+  /** Mark the email as the first in the thread of emails about this change. */
+  void markAsCreateChange();
+}
diff --git a/java/com/google/gerrit/server/mail/send/StartReviewChangeEmailDecoratorImpl.java b/java/com/google/gerrit/server/mail/send/StartReviewChangeEmailDecoratorImpl.java
new file mode 100644
index 0000000..67bf95b
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/StartReviewChangeEmailDecoratorImpl.java
@@ -0,0 +1,147 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.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.NotifyConfig.NotifyType;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/** Sends an email alerting a user to a new change for them to review. */
+public class StartReviewChangeEmailDecoratorImpl implements StartReviewChangeEmailDecorator {
+  protected OutgoingEmail email;
+  protected ChangeEmail changeEmail;
+
+  protected final Set<Account.Id> reviewers = new HashSet<>();
+  protected final Set<Address> reviewersByEmail = new HashSet<>();
+  protected final Set<Account.Id> extraCC = new HashSet<>();
+  protected final Set<Address> extraCCByEmail = new HashSet<>();
+  protected final Set<Account.Id> removedReviewers = new HashSet<>();
+  protected final Set<Address> removedByEmailReviewers = new HashSet<>();
+  protected boolean isCreateChange = false;
+
+  @Override
+  public void addReviewers(Collection<Account.Id> cc) {
+    reviewers.addAll(cc);
+  }
+
+  @Override
+  public void addReviewersByEmail(Collection<Address> cc) {
+    reviewersByEmail.addAll(cc);
+  }
+
+  @Override
+  public void addExtraCC(Collection<Account.Id> cc) {
+    extraCC.addAll(cc);
+  }
+
+  @Override
+  public void addExtraCCByEmail(Collection<Address> cc) {
+    extraCCByEmail.addAll(cc);
+  }
+
+  @Override
+  public void addRemovedReviewers(Collection<Account.Id> removed) {
+    removedReviewers.addAll(removed);
+  }
+
+  @Override
+  public void addRemovedByEmailReviewers(Collection<Address> removed) {
+    removedByEmailReviewers.addAll(removed);
+  }
+
+  @Override
+  public void markAsCreateChange() {
+    isCreateChange = true;
+  }
+
+  @Override
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+  }
+
+  @Nullable
+  protected List<String> getReviewerNames() {
+    if (reviewers.isEmpty()) {
+      return null;
+    }
+    List<String> names = new ArrayList<>();
+    for (Account.Id id : reviewers) {
+      names.add(email.getNameFor(id));
+    }
+    return names;
+  }
+
+  @Nullable
+  protected List<String> getRemovedReviewerNames() {
+    if (removedReviewers.isEmpty() && removedByEmailReviewers.isEmpty()) {
+      return null;
+    }
+    List<String> names = new ArrayList<>();
+    for (Account.Id id : removedReviewers) {
+      names.add(email.getNameFor(id));
+    }
+    for (Address address : removedByEmailReviewers) {
+      names.add(address.toString());
+    }
+    return names;
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyParam("ownerName", email.getNameFor(changeEmail.getChange().getOwner()));
+    email.addSoyEmailDataParam("reviewerNames", getReviewerNames());
+    email.addSoyEmailDataParam("removedReviewerNames", getRemovedReviewerNames());
+
+    switch (email.getNotify().handling()) {
+      case NONE:
+      case OWNER:
+        break;
+      case ALL:
+      default:
+        extraCC.stream().forEach(cc -> email.addByAccountId(RecipientType.CC, cc));
+        extraCCByEmail.stream().forEach(cc -> email.addByEmail(RecipientType.CC, cc));
+      // $FALL-THROUGH$
+      case OWNER_REVIEWERS:
+        reviewers.stream().forEach(r -> email.addByAccountId(RecipientType.TO, r, true));
+        reviewersByEmail.stream().forEach(r -> email.addByEmail(RecipientType.TO, r, true));
+        removedReviewers.stream().forEach(r -> email.addByAccountId(RecipientType.TO, r, true));
+        removedByEmailReviewers.stream().forEach(r -> email.addByEmail(RecipientType.TO, r, true));
+        break;
+    }
+
+    if (isCreateChange) {
+      changeEmail.addAuthors(RecipientType.CC);
+      changeEmail.includeWatchers(
+          NotifyType.NEW_CHANGES,
+          !changeEmail.getChange().isWorkInProgress() && !changeEmail.getChange().isPrivate());
+      changeEmail.includeWatchers(
+          NotifyType.NEW_PATCHSETS,
+          !changeEmail.getChange().isWorkInProgress() && !changeEmail.getChange().isPrivate());
+    }
+
+    email.appendText(email.textTemplate("NewChange"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("NewChangeHtml"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mime/MimeUtil2Module.java b/java/com/google/gerrit/server/mime/MimeUtil2Module.java
index 7fdc4fb..4502a9f 100644
--- a/java/com/google/gerrit/server/mime/MimeUtil2Module.java
+++ b/java/com/google/gerrit/server/mime/MimeUtil2Module.java
@@ -22,6 +22,7 @@
 import eu.medsea.mimeutil.detector.ExtensionMimeDetector;
 import eu.medsea.mimeutil.detector.MagicMimeMimeDetector;
 
+@SuppressWarnings("ProvidesMethodOutsideOfModule")
 public class MimeUtil2Module extends AbstractModule {
   @Override
   protected void configure() {}
diff --git a/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java b/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java
index 0289e17..d51b831 100644
--- a/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java
@@ -22,9 +22,13 @@
 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.git.RefUpdateUtil;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.FanOutExecutor;
+import com.google.gerrit.server.account.AccountState;
 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.update.context.RefUpdateContext;
 import com.google.inject.Inject;
@@ -46,7 +50,8 @@
   private final ExecutorService executor;
   private final AllUsersName allUsersName;
   private final GitRepositoryManager repoManager;
-  private final ListMultimap<String, ChangeDraftUpdate> draftUpdates;
+  private final GitReferenceUpdated gitReferenceUpdated;
+  private final ListMultimap<String, ChangeDraftNotesUpdate> draftUpdates;
 
   private PersonIdent serverIdent;
 
@@ -54,20 +59,22 @@
   AllUsersAsyncUpdate(
       @FanOutExecutor ExecutorService executor,
       AllUsersName allUsersName,
-      GitRepositoryManager repoManager) {
+      GitRepositoryManager repoManager,
+      GitReferenceUpdated gitReferenceUpdated) {
     this.executor = executor;
     this.allUsersName = allUsersName;
     this.repoManager = repoManager;
+    this.gitReferenceUpdated = gitReferenceUpdated;
     this.draftUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
   }
 
-  void setDraftUpdates(ListMultimap<String, ChangeDraftUpdate> draftUpdates) {
+  void setDraftUpdates(ListMultimap<String, ChangeDraftNotesUpdate> draftUpdates) {
     checkState(isEmpty(), "attempted to set draft comment updates for async execution twice");
     boolean allPublishOnly =
-        draftUpdates.values().stream().allMatch(ChangeDraftUpdate::canRunAsync);
+        draftUpdates.values().stream().allMatch(ChangeDraftNotesUpdate::canRunAsync);
     checkState(allPublishOnly, "not all updates can be run asynchronously");
     // Add deep copies to avoid any threading issues.
-    for (Map.Entry<String, ChangeDraftUpdate> entry : draftUpdates.entries()) {
+    for (Map.Entry<String, ChangeDraftNotesUpdate> entry : draftUpdates.entries()) {
       this.draftUpdates.put(entry.getKey(), entry.getValue().copy());
     }
     if (draftUpdates.size() > 0) {
@@ -83,7 +90,11 @@
   }
 
   /** Executes repository update asynchronously. No-op in case no updates were scheduled. */
-  void execute(PersonIdent refLogIdent, String refLogMessage, PushCertificate pushCert) {
+  void execute(
+      PersonIdent refLogIdent,
+      String refLogMessage,
+      PushCertificate pushCert,
+      CurrentUser currentUser) {
     if (isEmpty()) {
       return;
     }
@@ -110,6 +121,7 @@
                   allUsersRepo.cmds.addTo(bru);
                   bru.setAllowNonFastForwards(true);
                   RefUpdateUtil.executeChecked(bru, allUsersRepo.rw);
+                  gitReferenceUpdated.fire(allUsersName, bru, getAccountState(currentUser));
                 } catch (IOException e) {
                   logger.atSevere().withCause(e).log(
                       "Failed to delete draft comments asynchronously after publishing them");
@@ -117,4 +129,9 @@
               }
             });
   }
+
+  @Nullable
+  private static AccountState getAccountState(CurrentUser user) {
+    return user.isIdentifiedUser() ? user.asIdentifiedUser().state() : null;
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftNotesUpdate.java
similarity index 83%
rename from java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
rename to java/com/google/gerrit/server/notedb/ChangeDraftNotesUpdate.java
index c219387..b15cb50 100644
--- a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftNotesUpdate.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.ChangeDraftUpdate;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.query.change.ChangeNumberVirtualIdAlgorithm;
@@ -39,6 +40,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
@@ -55,18 +57,20 @@
  *
  * <p>This class is not thread safe.
  */
-public class ChangeDraftUpdate extends AbstractChangeUpdate {
+public class ChangeDraftNotesUpdate extends AbstractChangeUpdate implements ChangeDraftUpdate {
   private final ChangeNumberVirtualIdAlgorithm virtualIdFunc;
 
-  public interface Factory {
-    ChangeDraftUpdate create(
+  public interface Factory extends ChangeDraftUpdateFactory {
+    @Override
+    ChangeDraftNotesUpdate create(
         ChangeNotes notes,
         @Assisted("effective") Account.Id accountId,
         @Assisted("real") Account.Id realAccountId,
         PersonIdent authorIdent,
         Instant when);
 
-    ChangeDraftUpdate create(
+    @Override
+    ChangeDraftNotesUpdate create(
         Change change,
         @Assisted("effective") Account.Id accountId,
         @Assisted("real") Account.Id realAccountId,
@@ -87,8 +91,8 @@
     FIXED
   }
 
-  private static Key key(HumanComment c) {
-    return new AutoValue_ChangeDraftUpdate_Key(c.getCommitId(), c.key);
+  private static Key key(Comment c) {
+    return new AutoValue_ChangeDraftNotesUpdate_Key(c.getCommitId(), c.key);
   }
 
   private final AllUsersName draftsProject;
@@ -98,7 +102,7 @@
 
   @SuppressWarnings("UnusedMethod")
   @AssistedInject
-  private ChangeDraftUpdate(
+  private ChangeDraftNotesUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
       AllUsersName allUsers,
       ChangeNoteUtil noteUtil,
@@ -114,7 +118,7 @@
   }
 
   @AssistedInject
-  private ChangeDraftUpdate(
+  private ChangeDraftNotesUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
       AllUsersName allUsers,
       ChangeNoteUtil noteUtil,
@@ -129,44 +133,37 @@
     this.virtualIdFunc = virtualIdFunc;
   }
 
-  public void putComment(HumanComment c) {
+  @Override
+  public void putDraftComment(HumanComment c) {
     checkState(!put.contains(c), "comment already added");
     verifyComment(c);
     put.add(c);
   }
 
-  /**
-   * Marks a comment for deletion. Called when the comment is deleted because the user published it.
-   */
-  public void markCommentPublished(HumanComment c) {
+  @Override
+  public void markDraftCommentAsPublished(HumanComment c) {
     checkState(!delete.containsKey(key(c)), "comment already marked for deletion");
     verifyComment(c);
     delete.put(key(c), DeleteReason.PUBLISHED);
   }
 
-  /**
-   * Marks a comment for deletion. Called when the comment is deleted because the user removed it.
-   */
-  public void deleteComment(HumanComment c) {
+  @Override
+  public void addDraftCommentForDeletion(HumanComment c) {
     checkState(!delete.containsKey(key(c)), "comment already marked for deletion");
     verifyComment(c);
     delete.put(key(c), DeleteReason.DELETED);
   }
 
-  /**
-   * Marks a comment for deletion. Called when the comment should have been deleted previously, but
-   * wasn't, so we're fixing it up.
-   */
-  public void deleteComment(ObjectId commitId, Comment.Key key) {
-    Key commentKey = new AutoValue_ChangeDraftUpdate_Key(commitId, key);
-    checkState(!delete.containsKey(commentKey), "comment already marked for deletion");
-    delete.put(commentKey, DeleteReason.FIXED);
+  @Override
+  public void addAllDraftCommentsForDeletion(List<Comment> comments) {
+    comments.forEach(
+        comment -> {
+          Key commentKey = key(comment);
+          checkState(!delete.containsKey(commentKey), "comment already marked for deletion");
+          delete.put(commentKey, DeleteReason.FIXED);
+        });
   }
 
-  /**
-   * Returns true if all we do in this operations is deletes caused by publishing or fixing up
-   * comments.
-   */
   public boolean canRunAsync() {
     return put.isEmpty()
         && delete.values().stream()
@@ -174,15 +171,16 @@
   }
 
   /**
-   * Returns a copy of the current {@link ChangeDraftUpdate} that contains references to all
-   * deletions. Copying of {@link ChangeDraftUpdate} is only allowed if it contains no new comments.
+   * Returns a copy of the current {@link ChangeDraftNotesUpdate} that contains references to all
+   * deletions. Copying of {@link ChangeDraftNotesUpdate} is only allowed if it contains no new
+   * comments.
    */
-  ChangeDraftUpdate copy() {
+  ChangeDraftNotesUpdate copy() {
     checkState(
         put.isEmpty(),
-        "copying ChangeDraftUpdate is allowed only if it doesn't contain new comments");
-    ChangeDraftUpdate clonedUpdate =
-        new ChangeDraftUpdate(
+        "copying ChangeDraftNotesUpdate is allowed only if it doesn't contain new comments");
+    ChangeDraftNotesUpdate clonedUpdate =
+        new ChangeDraftNotesUpdate(
             authorIdent,
             draftsProject,
             noteUtil,
@@ -269,7 +267,7 @@
       noteMap = NoteMap.newEmptyMap();
     }
     // Even though reading from changes might not be enabled, we need to
-    // parse any existing revision notes so we can merge them.
+    // parse any existing revision notes, so we can merge them.
     return RevisionNoteMap.parse(
         noteUtil.getChangeNoteJson(), rw.getObjectReader(), noteMap, HumanComment.Status.DRAFT);
   }
@@ -306,6 +304,17 @@
     return delete.isEmpty() && put.isEmpty();
   }
 
+  public static Optional<ChangeDraftNotesUpdate> asChangeDraftNotesUpdate(
+      @Nullable ChangeDraftUpdate obj) {
+    if (obj == null) {
+      return Optional.empty();
+    }
+    if (obj instanceof ChangeDraftNotesUpdate) {
+      return Optional.of((ChangeDraftNotesUpdate) obj);
+    }
+    return Optional.empty();
+  }
+
   private Change.Id getVirtualId() {
     Change change = getChange();
     return virtualIdFunc == null
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java b/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java
index 3be55ea..eb1f692 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java
@@ -23,6 +23,7 @@
   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_CUSTOM_KEYED_VALUE = new FooterKey("Custom-Keyed-Value");
   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");
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index da531e3..cee98d1 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -16,6 +16,7 @@
 
 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.RefNames.changeMetaRef;
 import static java.util.Comparator.comparing;
 
@@ -29,7 +30,6 @@
 import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Multimaps;
 import com.google.common.collect.Ordering;
 import com.google.common.flogger.FluentLogger;
 import com.google.errorprone.annotations.FormatMethod;
@@ -389,6 +389,7 @@
 
   // Lazy defensive copies of mutable ReviewDb types, to avoid polluting the
   // ChangeNotesCache from handlers.
+  private ImmutableSortedMap<String, String> customKeyedValues;
   private ImmutableSortedMap<PatchSet.Id, PatchSet> patchSets;
   private PatchSetApprovals approvals;
   private ImmutableSet<Comment.Key> commentKeys;
@@ -472,6 +473,16 @@
     return state.allAttentionSetUpdates();
   }
 
+  /** Returns the key-value pairs that are attached to this change */
+  public ImmutableSortedMap<String, String> getCustomKeyedValues() {
+    if (customKeyedValues == null) {
+      ImmutableSortedMap.Builder<String, String> b = ImmutableSortedMap.naturalOrder();
+      b.putAll(state.customKeyedValues());
+      customKeyedValues = b.build();
+    }
+    return customKeyedValues;
+  }
+
   /**
    * Returns the evaluated submit requirements for the change. We only intend to store submit
    * requirements in NoteDb for closed changes. For closed changes, the results represent the state
@@ -538,24 +549,27 @@
     return Optional.ofNullable(state.mergedOn());
   }
 
-  public ImmutableListMultimap<ObjectId, HumanComment> getDraftComments(Account.Id author) {
+  public ImmutableList<HumanComment> getDraftComments(Account.Id author) {
     return getDraftComments(author, null, null);
   }
 
-  public ImmutableListMultimap<ObjectId, HumanComment> getDraftComments(
-      Account.Id author, @Nullable Change.Id virtualId) {
+  public ImmutableList<HumanComment> getDraftComments(Account.Id author, Ref ref) {
+    return getDraftComments(author, null, ref);
+  }
+
+  public ImmutableList<HumanComment> getDraftComments(Account.Id author, Change.Id virtualId) {
     return getDraftComments(author, virtualId, null);
   }
 
-  public ImmutableListMultimap<ObjectId, HumanComment> getDraftComments(
+  ImmutableList<HumanComment> getDraftComments(
       Account.Id author, @Nullable Change.Id virtualId, @Nullable Ref ref) {
     loadDraftComments(author, virtualId, ref);
     // Filter out any zombie draft comments. These are drafts that are also in
     // the published map, and arise when the update to All-Users to delete them
     // during the publish operation failed.
-    return ImmutableListMultimap.copyOf(
-        Multimaps.filterEntries(
-            draftCommentNotes.getComments(), e -> !getCommentKeys().contains(e.getValue().key)));
+    return draftCommentNotes.getComments().stream()
+        .filter(d -> !getCommentKeys().contains(d.key))
+        .collect(toImmutableList());
   }
 
   public ImmutableListMultimap<ObjectId, RobotComment> getRobotComments() {
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index b98ecdd..0566316 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -62,7 +62,7 @@
             .weigher(Weigher.class)
             .maximumWeight(10 << 20)
             .diskLimit(-1)
-            .version(5)
+            .version(8)
             .keySerializer(Key.Serializer.INSTANCE)
             .valueSerializer(ChangeNotesState.Serializer.INSTANCE);
       }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index b031839..1c4f2d7 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 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;
@@ -22,6 +23,7 @@
 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_CUSTOM_KEYED_VALUE;
 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;
@@ -45,8 +47,10 @@
 import com.google.common.base.Enums;
 import com.google.common.base.Splitter;
 import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
@@ -86,7 +90,6 @@
 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;
@@ -98,6 +101,7 @@
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import java.util.TreeMap;
 import java.util.TreeSet;
 import java.util.function.Function;
 import java.util.stream.Collectors;
@@ -168,11 +172,15 @@
   private final Set<PatchSet.Id> deletedPatchSets;
   private final Map<PatchSet.Id, PatchSetState> patchSetStates;
   private final List<PatchSet.Id> currentPatchSets;
+  private final TreeMap<String, String> customKeyedValues;
   private final Map<PatchSetApproval.Key, PatchSetApproval.Builder> approvals;
   private final List<PatchSetApproval.Builder> bufferedApprovals;
   private final List<ChangeMessage> allChangeMessages;
 
+  private final Set<Account.Id> removedReviewers;
+
   // Non-final private members filled in during the parsing process.
+  private Map<PatchSet.Id, String> branchByPatchSet;
   private String branch;
   private Change.Status status;
   private String topic;
@@ -224,6 +232,7 @@
     allPastReviewers = new ArrayList<>();
     reviewerUpdates = new ArrayList<>();
     latestAttentionStatus = new HashMap<>();
+    branchByPatchSet = new HashMap<>();
     allAttentionSetUpdates = new ArrayList<>();
     submitRecords = Lists.newArrayListWithExpectedSize(1);
     allChangeMessages = new ArrayList<>();
@@ -233,6 +242,8 @@
     deletedPatchSets = new HashSet<>();
     patchSetStates = new HashMap<>();
     currentPatchSets = new ArrayList<>();
+    customKeyedValues = new TreeMap<>();
+    removedReviewers = new HashSet<>();
   }
 
   ChangeNotesState parseAll() throws ConfigInvalidException, IOException {
@@ -263,6 +274,7 @@
       checkMandatoryFooters();
     }
 
+    pruneEmptyCustomKeyedValues();
     return buildState();
   }
 
@@ -287,6 +299,7 @@
         submissionId,
         status,
         firstNonNull(hashtags, ImmutableSet.of()),
+        ImmutableSortedMap.copyOfSorted(customKeyedValues),
         buildPatchSets(),
         buildApprovals(),
         ReviewerSet.fromTable(Tables.transpose(reviewers)),
@@ -314,7 +327,11 @@
     Map<PatchSet.Id, PatchSet> result = Maps.newHashMapWithExpectedSize(patchSets.size());
     for (Map.Entry<PatchSet.Id, PatchSet.Builder> e : patchSets.entrySet()) {
       try {
-        PatchSet ps = e.getValue().build();
+        PatchSet.Builder psBuilder = e.getValue();
+        if (branchByPatchSet.containsKey(e.getKey())) {
+          psBuilder.branch(Optional.of(branchByPatchSet.get(e.getKey())));
+        }
+        PatchSet ps = psBuilder.build();
         result.put(ps.id(), ps);
       } catch (Exception ex) {
         ConfigInvalidException cie = parseException("Error building patch set %s", e.getKey());
@@ -398,10 +415,10 @@
         continue;
       }
       // Search for an approval for this label on the max previous patch-set and copy the approval.
-      Collection<PatchSetApproval> userApprovals =
+      ImmutableList<PatchSetApproval> userApprovals =
           approvalsByUser.get(appliedBy).stream()
               .filter(approval -> approval.label().equals(labelName))
-              .collect(Collectors.toList());
+              .collect(toImmutableList());
       if (userApprovals.isEmpty()) {
         continue;
       }
@@ -446,10 +463,6 @@
     createdOn = commitTimestamp;
     parseTag(commit);
 
-    if (branch == null) {
-      branch = parseBranch(commit);
-    }
-
     PatchSet.Id psId = parsePatchSetId(commit);
     PatchSetState psState = parsePatchSetState(commit);
     if (psState != null) {
@@ -461,6 +474,35 @@
       }
     }
 
+    String currentBranch = parseBranch(commit);
+
+    if (branch == null) {
+      // The per-change branch is set from the latest change notes commit that has the branch footer
+      // only.
+      branch = currentBranch;
+    }
+
+    if (currentBranch != null) {
+      // Set current branch for this and later revisions
+      if (patchSets != null) {
+        // Change notes commits are parsed from the tip of the meta ref (which points at the
+        // last state of the change) backwards to the first commit.
+        // The branch footer is stored in the first change notes commit, then stored again if the
+        // change is moved. For example if a change has five patch-sets and the change was moved
+        // in PS2, then change notes commits will have the 'branch' footer at two of the commits
+        // representing PS1 and PS2.
+        // Due to our backwards traversal, once we have a value for 'branch', we propagate its
+        // value to the current and later patch-sets.
+        patchSets.keySet().stream()
+            .filter(p -> !branchByPatchSet.containsKey(p))
+            .forEach(p -> branchByPatchSet.put(p, currentBranch));
+      }
+      // Current patch-set is not yet available in patchSets. Check it as well.
+      if (!branchByPatchSet.containsKey(psId)) {
+        branchByPatchSet.put(psId, currentBranch);
+      }
+    }
+
     Account.Id accountId = parseIdent(commit);
     if (accountId != null) {
       ownerId = accountId;
@@ -490,6 +532,7 @@
     }
 
     parseHashtags(commit);
+    parseCustomKeyedValues(commit);
     parseAttentionSetUpdates(commit);
 
     parseSubmission(commit, commitTimestamp);
@@ -721,6 +764,30 @@
     }
   }
 
+  private void parseCustomKeyedValues(ChangeNotesCommit commit) {
+    for (String customKeyedValueLine : commit.getFooterLineValues(FOOTER_CUSTOM_KEYED_VALUE)) {
+      String[] parts = customKeyedValueLine.split("=", 2);
+      String key = parts[0];
+      String value = parts[1];
+      // Commits are parsed in reverse order and only the last set of values
+      // should be used.  An empty value for a key means it's a deletion.
+      customKeyedValues.putIfAbsent(key, value);
+    }
+  }
+
+  private void pruneEmptyCustomKeyedValues() {
+    List<String> toRemove = new ArrayList<>();
+    for (Map.Entry<String, String> entry : customKeyedValues.entrySet()) {
+      if (entry.getValue().length() == 0) {
+        toRemove.add(entry.getKey());
+      }
+    }
+
+    for (String key : toRemove) {
+      customKeyedValues.remove(key);
+    }
+  }
+
   private void parseAttentionSetUpdates(ChangeNotesCommit commit) throws ConfigInvalidException {
     List<String> attentionStrings = commit.getFooterLineValues(FOOTER_ATTENTION);
     for (String attentionString : attentionStrings) {
@@ -1021,11 +1088,18 @@
       throw pe;
     }
 
+    // The ChangeNotesParser parses updates from newest to oldest.
+    // The removedReviewers stores all reviewers which were removed in the newer change notes. Their
+    // votes should be ignored (i.e. set to 0) because their votes should be removed together with
+    // reviewer.
+    // The value is set to 0 (instead of skipping it) similar to the parseRemoveApproval:
+    // the ChangeNotesParser uses putIfAbsent to put a new approval into the approvals; storing 0
+    // as a value prevents any updates of the approval by an older noteDb's commit.
     PatchSetApproval.Builder psa =
         PatchSetApproval.builder()
             .key(PatchSetApproval.key(psId, approverId, LabelId.create(l.label())))
             .uuid(parsedPatchSetApproval.uuid().map(PatchSetApproval::uuid))
-            .value(l.value())
+            .value(removedReviewers.contains(approverId) ? 0 : l.value())
             .granted(ts)
             .tag(Optional.ofNullable(tag));
     if (!Objects.equals(realAccountId, committerId)) {
@@ -1161,7 +1235,12 @@
       throw invalidFooter(state.getFooterKey(), line);
     }
     Account.Id accountId = parseIdent(ident);
-    reviewerUpdates.add(ReviewerStatusUpdate.create(ts, ownerId, accountId, state));
+    ReviewerStatusUpdate update = ReviewerStatusUpdate.create(ts, ownerId, accountId, state);
+    reviewerUpdates.add(update);
+    if (update.state() == ReviewerStateInternal.REMOVED) {
+      removedReviewers.add(accountId);
+    }
+
     if (!reviewers.containsRow(accountId)) {
       reviewers.put(accountId, state, ts);
     }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 1b13078..6b208f3 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -28,6 +28,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Maps;
@@ -111,6 +112,7 @@
       @Nullable String submissionId,
       @Nullable Change.Status status,
       Set<String> hashtags,
+      ImmutableSortedMap<String, String> customKeyedValues,
       Map<PatchSet.Id, PatchSet> patchSets,
       ListMultimap<PatchSet.Id, PatchSetApproval> approvals,
       ReviewerSet reviewers,
@@ -163,6 +165,7 @@
                 .cherryPickOf(cherryPickOf)
                 .build())
         .hashtags(hashtags)
+        .customKeyedValues(customKeyedValues.entrySet())
         .serverId(serverId)
         .patchSets(patchSets.entrySet())
         .approvals(approvals.entries())
@@ -290,6 +293,16 @@
   // Other related to this Change.
   abstract ImmutableSet<String> hashtags();
 
+  /*
+    Custom values are small key value pairs. They can be used to associate the
+    change with external, potentially proprietary systems (e.g. Bug trackers)
+    without requiring dedicated fields in Gerrit-core.
+
+    This data is visible to everyone who can see the change. It must not contain
+    personally identify-able information.
+  */
+  abstract ImmutableList<Map.Entry<String, String>> customKeyedValues();
+
   @Nullable
   abstract String serverId();
 
@@ -385,6 +398,7 @@
       return new AutoValue_ChangeNotesState.Builder()
           .changeId(changeId)
           .hashtags(ImmutableSet.of())
+          .customKeyedValues(ImmutableList.of())
           .patchSets(ImmutableList.of())
           .approvals(ImmutableList.of())
           .reviewers(ReviewerSet.empty())
@@ -412,6 +426,8 @@
 
     abstract Builder hashtags(Iterable<String> hashtags);
 
+    abstract Builder customKeyedValues(Iterable<Map.Entry<String, String>> customKeyedValues);
+
     abstract Builder patchSets(Iterable<Map.Entry<PatchSet.Id, PatchSet>> patchSets);
 
     abstract Builder approvals(Iterable<Map.Entry<PatchSet.Id, PatchSetApproval>> approvals);
@@ -476,6 +492,14 @@
         b.setHasServerId(true);
       }
       object.hashtags().forEach(b::addHashtag);
+
+      object
+          .customKeyedValues()
+          .forEach(
+              entry -> {
+                b.putCustomKeyedValues(entry.getKey(), entry.getValue());
+              });
+
       object
           .patchSets()
           .forEach(e -> b.addPatchSet(PatchSetProtoConverter.INSTANCE.toProto(e.getValue())));
@@ -615,6 +639,7 @@
               .columns(toChangeColumns(changeId, proto.getColumns()))
               .serverId(proto.getHasServerId() ? proto.getServerId() : null)
               .hashtags(proto.getHashtagList())
+              .customKeyedValues(proto.getCustomKeyedValuesMap().entrySet())
               .patchSets(
                   proto.getPatchSetList().stream()
                       .map(msg -> PatchSetProtoConverter.INSTANCE.fromProto(msg))
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 0a895fb..17f41d4 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -25,6 +25,7 @@
 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_CUSTOM_KEYED_VALUE;
 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;
@@ -75,10 +76,12 @@
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.server.ChangeDraftUpdate;
 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.git.validators.TopicValidator;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.util.AttentionSetUtil;
@@ -100,6 +103,7 @@
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import java.util.TreeMap;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -138,8 +142,12 @@
         ChangeNotes notes, CurrentUser user, Instant when, Comparator<String> labelNameComparator);
   }
 
+  public static final int MAX_CUSTOM_KEY_LENGTH = 100;
+  public static final int MAX_CUSTOM_KEYED_VALUE_LENGTH = 1000;
+  public static final int MAX_CUSTOM_KEYED_VALUES = 100;
+
   private final NoteDbUpdateManager.Factory updateManagerFactory;
-  private final ChangeDraftUpdate.Factory draftUpdateFactory;
+  private final ChangeDraftUpdate.ChangeDraftUpdateFactory draftUpdateFactory;
   private final RobotCommentUpdate.Factory robotCommentUpdateFactory;
   private final DeleteCommentRewriter.Factory deleteCommentRewriterFactory;
   private final ServiceUserClassifier serviceUserClassifier;
@@ -163,10 +171,11 @@
   private Map<Account.Id, AttentionSetUpdate> plannedAttentionSetUpdates;
   private boolean ignoreFurtherAttentionSetUpdates;
   private Set<String> hashtags;
+  private TreeMap<String, String> customKeyedValues = new TreeMap<>();
   private String changeMessage;
   private String tag;
   private PatchSetState psState;
-  private Iterable<String> groups;
+  private List<String> groups;
   private String pushCert;
   private boolean isAllowWriteToNewtRef;
   private String psDescription;
@@ -187,12 +196,14 @@
   private ImmutableList.Builder<AttentionSetUpdate> attentionSetUpdatesBuilder =
       ImmutableList.builder();
 
+  private final CurrentUser user;
+
   @SuppressWarnings("UnusedMethod")
   @AssistedInject
   private ChangeUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
       NoteDbUpdateManager.Factory updateManagerFactory,
-      ChangeDraftUpdate.Factory draftUpdateFactory,
+      ChangeDraftUpdate.ChangeDraftUpdateFactory draftUpdateFactory,
       RobotCommentUpdate.Factory robotCommentUpdateFactory,
       DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
       ProjectCache projectCache,
@@ -230,7 +241,7 @@
   private ChangeUpdate(
       @GerritPersonIdent PersonIdent serverIdent,
       NoteDbUpdateManager.Factory updateManagerFactory,
-      ChangeDraftUpdate.Factory draftUpdateFactory,
+      ChangeDraftUpdate.ChangeDraftUpdateFactory draftUpdateFactory,
       RobotCommentUpdate.Factory robotCommentUpdateFactory,
       DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
       ServiceUserClassifier serviceUserClassifier,
@@ -248,11 +259,13 @@
     this.serviceUserClassifier = serviceUserClassifier;
     this.patchSetApprovalUuidGenerator = patchSetApprovalUuidGenerator;
     this.approvals = approvals(labelNameComparator);
+    this.user = user;
   }
 
   public ObjectId commit() throws IOException {
     try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
-      try (NoteDbUpdateManager updateManager = updateManagerFactory.create(getProjectName())) {
+      try (NoteDbUpdateManager updateManager =
+          updateManagerFactory.create(getProjectName(), user)) {
         updateManager.add(this);
         updateManager.execute();
       }
@@ -381,10 +394,10 @@
     verifyComment(c);
     createDraftUpdateIfNull();
     if (status == HumanComment.Status.DRAFT) {
-      draftUpdate.putComment(c);
+      draftUpdate.putDraftComment(c);
     } else {
       comments.add(c);
-      draftUpdate.markCommentPublished(c);
+      draftUpdate.markDraftCommentAsPublished(c);
     }
   }
 
@@ -396,7 +409,7 @@
 
   public void deleteComment(HumanComment c) {
     verifyComment(c);
-    createDraftUpdateIfNull().deleteComment(c);
+    createDraftUpdateIfNull().addDraftCommentForDeletion(c);
   }
 
   public void deleteCommentByRewritingHistory(String uuid, String newMessage) {
@@ -438,8 +451,8 @@
     }
   }
 
-  public void setTopic(String topic) throws ValidationException {
-
+  public void setTopic(String topic, TopicValidator validator) throws ValidationException {
+    validator.validateSize(topic);
     if (isIllegalTopic(topic)) {
       throw new ValidationException("topic can't contain quotation marks.");
     }
@@ -462,6 +475,23 @@
     this.hashtags = hashtags;
   }
 
+  public void addCustomKeyedValue(String key, String value) throws ValidationException {
+    if (key.length() > MAX_CUSTOM_KEY_LENGTH) {
+      throw new ValidationException("Custom Key is too long.");
+    }
+    if (value.length() > MAX_CUSTOM_KEYED_VALUE_LENGTH) {
+      throw new ValidationException("Custom Keyed value is too long.");
+    }
+    this.customKeyedValues.put(key, value);
+  }
+
+  public void deleteCustomKeyedValue(String key) throws ValidationException {
+    if (key.length() > MAX_CUSTOM_KEY_LENGTH) {
+      throw new ValidationException("Custom Key is too long.");
+    }
+    this.customKeyedValues.put(key, "");
+  }
+
   /**
    * Adds attention set updates that should be stored in NoteDb.
    *
@@ -637,29 +667,31 @@
       Map<ObjectId, RevisionNoteBuilder> toUpdate) {
     // Prohibit various kinds of illegal operations on comments.
     Set<Comment.Key> existing = new HashSet<>();
+    List<Comment> draftsToFix = new ArrayList<>();
     for (ChangeRevisionNote rn : existingNotes.values()) {
       for (Comment c : rn.getEntities()) {
         existing.add(c.key);
-        if (draftUpdate != null) {
-          // Take advantage of an existing update on All-Users to prune any
-          // published comments from drafts. NoteDbUpdateManager takes care of
-          // ensuring that this update is applied before its dependent draft
-          // update.
-          //
-          // Deleting aggressively in this way, combined with filtering out
-          // duplicate published/draft comments in ChangeNotes#getDraftComments,
-          // makes up for the fact that updates between the change repo and
-          // All-Users are not atomic.
-          //
-          // TODO(dborowitz): We might want to distinguish between deleted
-          // drafts that we're fixing up after the fact by putting them in a
-          // separate commit. But note that we don't care much about the commit
-          // graph of the draft ref, particularly because the ref is completely
-          // deleted when all drafts are gone.
-          draftUpdate.deleteComment(c.getCommitId(), c.key);
-        }
+        draftsToFix.add(c);
       }
     }
+    if (draftUpdate != null) {
+      // Take advantage of an existing update on All-Users to prune any
+      // published comments from drafts. NoteDbUpdateManager takes care of
+      // ensuring that this update is applied before its dependent draft
+      // update.
+      //
+      // Deleting aggressively in this way, combined with filtering out
+      // duplicate published/draft comments in ChangeNotes#getDraftsByChangeAndDraftAuthor,
+      // makes up for the fact that updates between the change repo and
+      // All-Users are not atomic.
+      //
+      // TODO(dborowitz): We might want to distinguish between deleted
+      // drafts that we're fixing up after the fact by putting them in a
+      // separate commit. But note that we don't care much about the commit
+      // graph of the draft ref, particularly because the ref is completely
+      // deleted when all drafts are gone.
+      draftUpdate.addAllDraftCommentsForDeletion(draftsToFix);
+    }
 
     for (RevisionNoteBuilder b : toUpdate.values()) {
       for (Comment c : b.put.values()) {
@@ -764,6 +796,10 @@
       addFooter(msg, FOOTER_HASHTAGS, comma.join(hashtags));
     }
 
+    for (Map.Entry<String, String> entry : customKeyedValues.entrySet()) {
+      addFooter(msg, FOOTER_CUSTOM_KEYED_VALUE, entry.getKey() + "=" + entry.getValue());
+    }
+
     if (tag != null) {
       addFooter(msg, FOOTER_TAG, tag);
     }
@@ -1159,6 +1195,7 @@
         && submissionId == null
         && submitRecords == null
         && hashtags == null
+        && customKeyedValues.isEmpty()
         && topic == null
         && commit == null
         && psState == null
diff --git a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
index 3f3ede1..7d00d2c 100644
--- a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
+++ b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
@@ -19,21 +19,17 @@
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 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.collect.ListMultimap;
 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.DeleteZombieComments;
+import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -42,15 +38,11 @@
 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.Collection;
 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;
@@ -58,23 +50,11 @@
 import org.eclipse.jgit.transport.ReceiveCommand;
 
 /**
- * This class can be used to clean zombie draft comments refs. More context in <a
- * href="https://gerrit-review.googlesource.com/c/gerrit/+/246233">
- * https://gerrit-review.googlesource.com/c/gerrit/+/246233 </a>
+ * This class can be used to clean zombie draft comments from NoteDB.
  *
- * <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>
+ * <p>See {@link DeleteZombieComments} for more info.
  */
-public class DeleteZombieCommentsRefs {
+public class DeleteZombieCommentsRefs extends DeleteZombieComments<Ref> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   // Number of refs deleted at once in a batch ref-update.
@@ -83,23 +63,12 @@
 
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
-  private final int cleanupPercentage;
 
-  /**
-   * 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;
 
+  private Repository allUsersRepo;
+
   public interface Factory {
     DeleteZombieCommentsRefs create(int cleanupPercentage);
 
@@ -108,22 +77,22 @@
 
   @AssistedInject
   public DeleteZombieCommentsRefs(
-      AllUsersName allUsers,
+      @Assisted Integer cleanupPercentage,
       GitRepositoryManager repoManager,
+      AllUsersName allUsers,
+      DraftCommentsReader draftCommentsReader,
       ChangeNotes.Factory changeNotesFactory,
-      DraftCommentNotes.Factory draftNotesFactory,
       CommentsUtil commentsUtil,
       ChangeUpdate.Factory changeUpdateFactory,
-      IdentifiedUser.GenericFactory userFactory,
-      @Assisted Integer cleanupPercentage) {
+      IdentifiedUser.GenericFactory userFactory) {
     this(
-        allUsers,
-        repoManager,
         cleanupPercentage,
         /* dryRun= */ true,
         (msg) -> {},
+        repoManager,
+        allUsers,
+        draftCommentsReader,
         changeNotesFactory,
-        draftNotesFactory,
         commentsUtil,
         changeUpdateFactory,
         userFactory);
@@ -131,23 +100,23 @@
 
   @AssistedInject
   public DeleteZombieCommentsRefs(
-      AllUsersName allUsers,
+      @Assisted Integer cleanupPercentage,
+      @Assisted boolean dryRun,
       GitRepositoryManager repoManager,
+      AllUsersName allUsers,
+      DraftCommentsReader draftCommentsReader,
       ChangeNotes.Factory changeNotesFactory,
-      DraftCommentNotes.Factory draftNotesFactory,
       CommentsUtil commentsUtil,
       ChangeUpdate.Factory changeUpdateFactory,
-      IdentifiedUser.GenericFactory userFactory,
-      @Assisted Integer cleanupPercentage,
-      @Assisted boolean dryRun) {
+      IdentifiedUser.GenericFactory userFactory) {
     this(
-        allUsers,
-        repoManager,
         cleanupPercentage,
         dryRun,
         (msg) -> {},
+        repoManager,
+        allUsers,
+        draftCommentsReader,
         changeNotesFactory,
-        draftNotesFactory,
         commentsUtil,
         changeUpdateFactory,
         userFactory);
@@ -159,11 +128,11 @@
       Integer cleanupPercentage,
       Consumer<String> uiConsumer) {
     this(
-        allUsers,
-        repoManager,
         cleanupPercentage,
         /* dryRun= */ false,
         uiConsumer,
+        repoManager,
+        allUsers,
         null,
         null,
         null,
@@ -172,251 +141,95 @@
   }
 
   private DeleteZombieCommentsRefs(
-      AllUsersName allUsers,
-      GitRepositoryManager repoManager,
       Integer cleanupPercentage,
       boolean dryRun,
       Consumer<String> uiConsumer,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsers,
+      @Nullable DraftCommentsReader draftCommentsReader,
       @Nullable ChangeNotes.Factory changeNotesFactory,
-      @Nullable DraftCommentNotes.Factory draftNotesFactory,
       @Nullable CommentsUtil commentsUtil,
       @Nullable ChangeUpdate.Factory changeUpdateFactory,
       @Nullable IdentifiedUser.GenericFactory userFactory) {
+    super(
+        cleanupPercentage,
+        dryRun,
+        uiConsumer,
+        repoManager,
+        draftCommentsReader,
+        changeNotesFactory,
+        commentsUtil);
     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 {
-    deleteDraftRefsThatPointToEmptyTree();
-    if (draftNotesFactory != null) {
-      deleteDraftCommentsThatAreAlsoPublished();
-    }
+  @Override
+  public void setup() throws IOException {
+    allUsersRepo = repoManager.openRepository(allUsers);
   }
 
-  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);
+  @Override
+  public void close() throws IOException {
+    allUsersRepo.close();
+  }
 
-      logInfo(
-          String.format(
-              "Found a total of %d zombie draft refs in %s repo.",
-              zombieRefs.size(), allUsers.get()));
+  @Override
+  protected List<Ref> listAllDrafts() throws IOException {
+    return allUsersRepo.getRefDatabase().getRefsByPrefix(REFS_DRAFT_COMMENTS);
+  }
 
-      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);
-      }
-    }
+  @Override
+  protected List<Ref> listEmptyDrafts() throws IOException {
+    List<Ref> zombieRefs = filterZombieRefs(allUsersRepo, listAllDrafts());
+    logInfo(
+        String.format(
+            "Found a total of %d zombie draft refs in %s repo.",
+            zombieRefs.size(), allUsers.get()));
+    return zombieRefs;
   }
 
   /**
-   * 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.
+   * 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.
    */
-  @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;
+  @Override
+  protected void deleteEmptyDraftsByKey(Collection<Ref> refs) throws IOException {
+    long zombieRefsCnt = refs.size();
+    long deletedRefsCnt = 0;
+    long startTime = System.currentTimeMillis();
+
+    for (List<Ref> refsBatch : Iterables.partition(refs, CHUNK_SIZE)) {
+      deleteZombieDraftsBatch(refsBatch);
+      long elapsed = (System.currentTimeMillis() - startTime) / 1000;
+      deletedRefsCnt += refsBatch.size();
+      logProgress(deletedRefsCnt, zombieRefsCnt, elapsed);
     }
   }
 
-  @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);
+  @Override
+  protected void deleteZombieDrafts(ListMultimap<Ref, HumanComment> drafts) throws IOException {
+    for (Map.Entry<Ref, Collection<HumanComment>> e : drafts.asMap().entrySet()) {
+      deleteZombieDraftsForChange(
+          getAccountId(e.getKey()), getChangeNotes(getChangeId(e.getKey())), e.getValue());
     }
   }
 
-  /**
-   * 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()));
+  @Override
+  protected Change.Id getChangeId(Ref ref) {
+    return Change.Id.fromAllUsersRef(ref.getName());
   }
 
-  /**
-   * 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;
+  @Override
+  protected Account.Id getAccountId(Ref ref) {
+    return Account.Id.fromRef(ref.getName());
   }
 
-  /** 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 {
-    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
-      List<ReceiveCommand> deleteCommands =
-          refsBatch.stream()
-              .map(
-                  zombieRef ->
-                      new ReceiveCommand(
-                          zombieRef.getObjectId(), ObjectId.zeroId(), zombieRef.getName()))
-              .collect(toImmutableList());
-      BatchRefUpdate bru = allUsersRepo.getRefDatabase().newBatchUpdate();
-      bru.setAtomic(true);
-      bru.addCommand(deleteCommands);
-      RefUpdateUtil.executeChecked(bru, allUsersRepo);
-    }
+  @Override
+  protected String loggable(Ref ref) {
+    return ref.getName();
   }
 
   private List<Ref> filterZombieRefs(Repository allUsersRepo, List<Ref> allDraftRefs)
@@ -434,15 +247,46 @@
     return allUsersRepo.parseCommit(ref.getObjectId()).getTree().getId().equals(EMPTY_TREE_ID);
   }
 
-  private void logInfo(String message) {
-    logger.atInfo().log("%s", message);
-    uiConsumer.accept(message);
-  }
-
   private void logProgress(long deletedRefsCount, long allRefsCount, long elapsed) {
     logInfo(
         String.format(
             "Deleted %d/%d zombie draft refs (%d seconds)",
             deletedRefsCount, allRefsCount, elapsed));
   }
+
+  private void deleteZombieDraftsBatch(Collection<Ref> refsBatch) throws IOException {
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      List<ReceiveCommand> deleteCommands =
+          refsBatch.stream()
+              .map(
+                  zombieRef ->
+                      new ReceiveCommand(
+                          zombieRef.getObjectId(), ObjectId.zeroId(), zombieRef.getName()))
+              .collect(toImmutableList());
+      BatchRefUpdate bru = allUsersRepo.getRefDatabase().newBatchUpdate();
+      bru.setAtomic(true);
+      bru.addCommand(deleteCommands);
+      RefUpdateUtil.executeChecked(bru, allUsersRepo);
+    }
+  }
+
+  /**
+   * 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 deleteZombieDraftsForChange(
+      Account.Id accountId, ChangeNotes changeNotes, Collection<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(toImmutableList()));
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
index ae02708..639633e 100644
--- a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
@@ -19,6 +19,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
@@ -100,8 +101,8 @@
     return author;
   }
 
-  public ImmutableListMultimap<ObjectId, HumanComment> getComments() {
-    return comments;
+  public ImmutableList<HumanComment> getComments() {
+    return comments.values().asList();
   }
 
   public boolean containsComment(HumanComment c) {
diff --git a/java/com/google/gerrit/server/notedb/DraftCommentsNotesReader.java b/java/com/google/gerrit/server/notedb/DraftCommentsNotesReader.java
new file mode 100644
index 0000000..27c59f9
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/DraftCommentsNotesReader.java
@@ -0,0 +1,148 @@
+// 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.notedb;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.DraftCommentsReader;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+@Singleton
+public class DraftCommentsNotesReader implements DraftCommentsReader {
+  private final DraftCommentNotes.Factory draftCommentNotesFactory;
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsers;
+
+  @Inject
+  DraftCommentsNotesReader(
+      DraftCommentNotes.Factory draftCommentNotesFactory,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsers) {
+    this.draftCommentNotesFactory = draftCommentNotesFactory;
+    this.repoManager = repoManager;
+    this.allUsers = allUsers;
+  }
+
+  @Override
+  public Optional<HumanComment> getDraftComment(
+      ChangeNotes notes, IdentifiedUser author, Comment.Key key) {
+    return getDraftsByChangeAndDraftAuthor(notes, author.getAccountId()).stream()
+        .filter(c -> key.equals(c.key))
+        .findFirst();
+  }
+
+  @Override
+  public List<HumanComment> getDraftsByChangeAndDraftAuthor(ChangeNotes notes, Account.Id author) {
+    return sort(new ArrayList<>(notes.getDraftComments(author)));
+  }
+
+  @Override
+  public List<HumanComment> getDraftsByChangeAndDraftAuthor(Change.Id changeId, Account.Id author) {
+    return sort(
+        new ArrayList<>(draftCommentNotesFactory.create(changeId, author).load().getComments()));
+  }
+
+  @Override
+  public List<HumanComment> getDraftsByPatchSetAndDraftAuthor(
+      ChangeNotes notes, PatchSet.Id psId, Account.Id author) {
+    return sort(
+        notes.load().getDraftComments(author).stream()
+            .filter(c -> c.key.patchSetId == psId.get())
+            .collect(Collectors.toList()));
+  }
+
+  @Override
+  public List<HumanComment> getDraftsByChangeForAllAuthors(ChangeNotes notes) {
+    List<HumanComment> comments = new ArrayList<>();
+    for (Ref ref : getDraftRefs(notes)) {
+      Account.Id account = Account.Id.fromRefSuffix(ref.getName());
+      if (account != null) {
+        comments.addAll(getDraftsByChangeAndDraftAuthor(notes, account));
+      }
+    }
+    return sort(comments);
+  }
+
+  @Override
+  public Set<Account.Id> getUsersWithDrafts(ChangeNotes changeNotes) {
+    Set<Account.Id> res = new HashSet<>();
+    for (Ref ref : getDraftRefs(changeNotes)) {
+      Account.Id account = Account.Id.fromRefSuffix(ref.getName());
+      if (account != null
+          // Double-check that any drafts exist for this user after
+          // filtering out zombies. If some but not all drafts in the ref
+          // were zombies, the returned Ref still includes those zombies;
+          // this is suboptimal, but is ok for the purposes of
+          // draftsByUser(), and easier than trying to rebuild the change at
+          // this point.
+          && !changeNotes.getDraftComments(account, ref).isEmpty()) {
+        res.add(account);
+      }
+    }
+    return res;
+  }
+
+  @Override
+  public Set<Change.Id> getChangesWithDrafts(Account.Id author) {
+    Set<Change.Id> changes = new HashSet<>();
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_DRAFT_COMMENTS)) {
+        Integer accountIdFromRef = RefNames.parseRefSuffix(ref.getName());
+        if (accountIdFromRef != null && accountIdFromRef == author.get()) {
+          Change.Id changeId = Change.Id.fromAllUsersRef(ref.getName());
+          if (changeId == null) {
+            continue;
+          }
+          changes.add(changeId);
+        }
+      }
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+    return changes;
+  }
+
+  private List<Ref> getDraftRefs(ChangeNotes notes) {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return repo.getRefDatabase()
+          .getRefsByPrefix(RefNames.refsDraftCommentsPrefix(notes.getChangeId()));
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  private List<HumanComment> sort(List<HumanComment> comments) {
+    return CommentsUtil.sort(comments);
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/NoteDbModule.java b/java/com/google/gerrit/server/notedb/NoteDbModule.java
index d8a5fd5..31428cd 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbModule.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbModule.java
@@ -17,6 +17,11 @@
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.DraftCommentsReader;
+import com.google.gerrit.server.StarredChangesReader;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesWriter;
+import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Names;
 
@@ -37,13 +42,17 @@
 
   @Override
   public void configure() {
-    factory(ChangeDraftUpdate.Factory.class);
+    factory(ChangeDraftNotesUpdate.Factory.class);
     factory(ChangeUpdate.Factory.class);
     factory(DeleteCommentRewriter.Factory.class);
     factory(DraftCommentNotes.Factory.class);
     factory(NoteDbUpdateManager.Factory.class);
     factory(RobotCommentNotes.Factory.class);
     factory(RobotCommentUpdate.Factory.class);
+    bind(StarredChangesReader.class).to(StarredChangesUtilNoteDbImpl.class).in(Singleton.class);
+    bind(StarredChangesWriter.class).to(StarredChangesUtilNoteDbImpl.class).in(Singleton.class);
+    bind(StarredChangesUtil.class).to(StarredChangesUtilNoteDbImpl.class).in(Singleton.class);
+    bind(DraftCommentsReader.class).to(DraftCommentsNotesReader.class).in(Singleton.class);
 
     if (!useTestBindings) {
       install(ChangeNotesCache.module());
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index 645eb48..e7581b61 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -23,6 +23,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.common.Nullable;
@@ -34,6 +35,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.cancellation.RequestStateContext;
 import com.google.gerrit.server.cancellation.RequestStateContext.NonCancellableOperationContext;
@@ -81,7 +83,7 @@
   private static final int MAX_PATCH_SETS_DEFAULT = 1000;
 
   public interface Factory {
-    NoteDbUpdateManager create(Project.NameKey projectName);
+    NoteDbUpdateManager create(Project.NameKey projectName, CurrentUser currentUser);
   }
 
   private final Provider<PersonIdent> serverIdent;
@@ -91,8 +93,9 @@
   private final Project.NameKey projectName;
   private final int maxUpdates;
   private final int maxPatchSets;
+  private final CurrentUser currentUser;
   private final ListMultimap<String, ChangeUpdate> changeUpdates;
-  private final ListMultimap<String, ChangeDraftUpdate> draftUpdates;
+  private final ListMultimap<String, ChangeDraftNotesUpdate> draftUpdates;
   private final ListMultimap<String, RobotCommentUpdate> robotCommentUpdates;
   private final ListMultimap<String, NoteDbRewriter> rewriters;
   private final Set<Change.Id> changesToDelete;
@@ -114,7 +117,8 @@
       AllUsersName allUsersName,
       NoteDbMetrics metrics,
       AllUsersAsyncUpdate updateAllUsersAsync,
-      @Assisted Project.NameKey projectName) {
+      @Assisted Project.NameKey projectName,
+      @Assisted CurrentUser currentUser) {
     this.serverIdent = serverIdent;
     this.repoManager = repoManager;
     this.allUsersName = allUsersName;
@@ -123,6 +127,7 @@
     this.projectName = projectName;
     maxUpdates = cfg.getInt("change", null, "maxUpdates", MAX_UPDATES_DEFAULT);
     maxPatchSets = cfg.getInt("change", null, "maxPatchSets", MAX_PATCH_SETS_DEFAULT);
+    this.currentUser = currentUser;
     changeUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
     draftUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
     robotCommentUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
@@ -242,9 +247,10 @@
         "cannot update & rewrite ref %s in one BatchUpdate",
         update.getRefName());
 
-    ChangeDraftUpdate du = update.getDraftUpdate();
-    if (du != null) {
-      draftUpdates.put(du.getRefName(), du);
+    Optional<ChangeDraftNotesUpdate> du =
+        ChangeDraftNotesUpdate.asChangeDraftNotesUpdate(update.getDraftUpdate());
+    if (du.isPresent()) {
+      draftUpdates.put(du.get().getRefName(), du.get());
     }
     RobotCommentUpdate rcu = update.getRobotCommentUpdate();
     if (rcu != null) {
@@ -282,7 +288,7 @@
     changeUpdates.put(update.getRefName(), update);
   }
 
-  public void add(ChangeDraftUpdate draftUpdate) {
+  public void add(ChangeDraftNotesUpdate draftUpdate) {
     checkNotExecuted();
     draftUpdates.put(draftUpdate.getRefName(), draftUpdate);
   }
@@ -311,23 +317,24 @@
     }
   }
 
-  @Nullable
-  public BatchRefUpdate execute() throws IOException {
+  public ImmutableMultimap<Project.NameKey, BatchRefUpdate> execute() throws IOException {
     return execute(false);
   }
 
-  @Nullable
-  public BatchRefUpdate execute(boolean dryrun) throws IOException {
+  public ImmutableMultimap<Project.NameKey, BatchRefUpdate> execute(boolean dryrun)
+      throws IOException {
     checkNotExecuted();
+    ImmutableMultimap.Builder<Project.NameKey, BatchRefUpdate> resultBuilder =
+        ImmutableMultimap.builder();
     if (isEmpty()) {
       executed = true;
-      return null;
+      return resultBuilder.build();
     }
     try (Timer0.Context timer = metrics.updateLatency.start();
         NonCancellableOperationContext nonCancellableOperationContext =
             RequestStateContext.startNonCancellableOperation()) {
       stage();
-      // ChangeUpdates must execute before ChangeDraftUpdates.
+      // ChangeUpdates must execute before ChangeDraftNotesUpdates.
       //
       // ChangeUpdate will automatically delete draft comments for any published
       // comments, but the updates to the two repos don't happen atomically.
@@ -335,24 +342,23 @@
       // we may have stale draft comments. Doing it in this order allows stale
       // comments to be filtered out by ChangeNotes, reflecting the fact that
       // comments can only go from DRAFT to PUBLISHED, not vice versa.
-      BatchRefUpdate result;
       try (TraceContext.TraceTimer ignored =
           newTimer("NoteDbUpdateManager#updateRepo", Metadata.empty())) {
-        result = execute(changeRepo, dryrun, pushCert);
+        execute(changeRepo, dryrun, pushCert).ifPresent(bru -> resultBuilder.put(projectName, bru));
       }
       try (TraceContext.TraceTimer ignored =
           newTimer("NoteDbUpdateManager#updateAllUsersSync", Metadata.empty())) {
-        execute(allUsersRepo, dryrun, null);
+        execute(allUsersRepo, dryrun, null).ifPresent(bru -> resultBuilder.put(allUsersName, bru));
       }
       if (!dryrun) {
         // Only execute the asynchronous operation if we are not in dry-run mode: The dry run would
         // have to run synchronous to be of any value at all. For the removal of draft comments from
         // All-Users we don't care much of the operation succeeds, so we are skipping the dry run
         // altogether.
-        updateAllUsersAsync.execute(refLogIdent, refLogMessage, pushCert);
+        updateAllUsersAsync.execute(refLogIdent, refLogMessage, pushCert, currentUser);
       }
       executed = true;
-      return result;
+      return resultBuilder.build();
     } finally {
       close();
     }
@@ -366,11 +372,10 @@
                 cu -> cu.getAttentionSetUpdates().stream()));
   }
 
-  @Nullable
-  private BatchRefUpdate execute(OpenRepo or, boolean dryrun, @Nullable PushCertificate pushCert)
-      throws IOException {
+  private Optional<BatchRefUpdate> execute(
+      OpenRepo or, boolean dryrun, @Nullable PushCertificate pushCert) throws IOException {
     if (or == null || or.cmds.isEmpty()) {
-      return null;
+      return Optional.empty();
     }
     if (!dryrun) {
       or.flush();
@@ -399,13 +404,14 @@
     if (!dryrun) {
       RefUpdateUtil.executeChecked(bru, or.rw);
     }
-    return bru;
+    return Optional.of(bru);
   }
 
   private void addCommands() throws IOException {
     changeRepo.addUpdates(changeUpdates, Optional.of(maxUpdates), Optional.of(maxPatchSets));
     if (!draftUpdates.isEmpty()) {
-      boolean publishOnly = draftUpdates.values().stream().allMatch(ChangeDraftUpdate::canRunAsync);
+      boolean publishOnly =
+          draftUpdates.values().stream().allMatch(ChangeDraftNotesUpdate::canRunAsync);
       if (publishOnly) {
         updateAllUsersAsync.setDraftUpdates(draftUpdates);
       } else {
diff --git a/java/com/google/gerrit/server/notedb/RepoSequence.java b/java/com/google/gerrit/server/notedb/RepoSequence.java
index 9aaac19..bf2795d 100644
--- a/java/com/google/gerrit/server/notedb/RepoSequence.java
+++ b/java/com/google/gerrit/server/notedb/RepoSequence.java
@@ -18,9 +18,7 @@
 import static com.google.gerrit.entities.RefNames.REFS;
 import static com.google.gerrit.entities.RefNames.REFS_SEQUENCES;
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.REPO_SEQ;
-import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.github.rholder.retry.RetryException;
 import com.github.rholder.retry.Retryer;
@@ -36,11 +34,21 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.server.Sequence;
+import com.google.gerrit.server.Sequences;
+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.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Provides;
+import com.google.inject.name.Named;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
@@ -49,12 +57,11 @@
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
 
 /**
  * Class for managing an incrementing sequence backed by a git repository.
@@ -66,7 +73,7 @@
  * memory until they run out. This means concurrent processes will hand out somewhat non-monotonic
  * numbers.
  */
-public class RepoSequence {
+public class RepoSequence implements Sequence {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   @FunctionalInterface
@@ -74,6 +81,99 @@
     int get();
   }
 
+  public static class RepoSequenceModule extends FactoryModule {
+    public static final String SECTION_NOTE_DB = "noteDb";
+    public static final String KEY_SEQUENCE_BATCH_SIZE = "sequenceBatchSize";
+    public static final int DEFAULT_ACCOUNTS_SEQUENCE_BATCH_SIZE = 1;
+    public static final int DEFAULT_GROUPS_SEQUENCE_BATCH_SIZE = 1;
+    public static final int DEFAULT_CHANGES_SEQUENCE_BATCH_SIZE = 20;
+
+    @Provides
+    @Named(NAME_ACCOUNTS)
+    Sequence getAccountSequence(
+        @GerritServerConfig Config cfg,
+        GitRepositoryManager repoManager,
+        AllUsersName allUsers,
+        GitReferenceUpdated gitReferenceUpdated) {
+      int accountBatchSize =
+          cfg.getInt(
+              SECTION_NOTE_DB,
+              NAME_ACCOUNTS,
+              KEY_SEQUENCE_BATCH_SIZE,
+              DEFAULT_ACCOUNTS_SEQUENCE_BATCH_SIZE);
+      return new RepoSequence(
+          repoManager,
+          gitReferenceUpdated,
+          allUsers,
+          NAME_ACCOUNTS,
+          () -> Sequences.FIRST_ACCOUNT_ID,
+          accountBatchSize);
+    }
+
+    @Provides
+    @Named(NAME_GROUPS)
+    Sequence getGroupSequence(
+        GitRepositoryManager repoManager,
+        AllUsersName allUsers,
+        GitReferenceUpdated gitReferenceUpdated) {
+      return new RepoSequence(
+          repoManager,
+          gitReferenceUpdated,
+          allUsers,
+          NAME_GROUPS,
+          () -> Sequences.FIRST_GROUP_ID,
+          DEFAULT_GROUPS_SEQUENCE_BATCH_SIZE);
+    }
+
+    @Provides
+    @Named(NAME_CHANGES)
+    Sequence getChangesSequence(
+        @GerritServerConfig Config cfg,
+        GitRepositoryManager repoManager,
+        AllProjectsName allProjects,
+        GitReferenceUpdated gitReferenceUpdated) {
+      int changeBatchSize =
+          cfg.getInt(
+              SECTION_NOTE_DB,
+              NAME_CHANGES,
+              KEY_SEQUENCE_BATCH_SIZE,
+              DEFAULT_CHANGES_SEQUENCE_BATCH_SIZE);
+      return new RepoSequence(
+          repoManager,
+          gitReferenceUpdated,
+          allProjects,
+          NAME_CHANGES,
+          () -> Sequences.FIRST_CHANGE_ID,
+          changeBatchSize);
+    }
+  }
+
+  /** A groups sequence provider that does not fire git reference updates. */
+  public static class DisabledGitRefUpdatedRepoGroupsSequenceProvider
+      implements Provider<Sequence> {
+    private static final int DEFAULT_GROUPS_SEQUENCE_BATCH_SIZE = 1;
+    private final GitRepositoryManager repoManager;
+    private final AllUsersName allUsers;
+
+    @Inject
+    DisabledGitRefUpdatedRepoGroupsSequenceProvider(
+        GitRepositoryManager repoManager, AllUsersName allUsersName) {
+      this.repoManager = repoManager;
+      this.allUsers = allUsersName;
+    }
+
+    @Override
+    public Sequence get() {
+      return new RepoSequence(
+          repoManager,
+          GitReferenceUpdated.DISABLED,
+          allUsers,
+          NAME_GROUPS,
+          () -> Sequences.FIRST_GROUP_ID,
+          DEFAULT_GROUPS_SEQUENCE_BATCH_SIZE);
+    }
+  }
+
   @VisibleForTesting
   static RetryerBuilder<ImmutableList<Integer>> retryerBuilder() {
     return RetryerBuilder.<ImmutableList<Integer>>newBuilder()
@@ -127,26 +227,6 @@
         0);
   }
 
-  public RepoSequence(
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated gitRefUpdated,
-      Project.NameKey projectName,
-      String name,
-      Seed seed,
-      int batchSize,
-      int floor) {
-    this(
-        repoManager,
-        gitRefUpdated,
-        projectName,
-        name,
-        seed,
-        batchSize,
-        Runnables.doNothing(),
-        RETRYER,
-        floor);
-  }
-
   @VisibleForTesting
   RepoSequence(
       GitRepositoryManager repoManager,
@@ -160,7 +240,7 @@
     this(repoManager, gitRefUpdated, projectName, name, seed, batchSize, afterReadRef, retryer, 0);
   }
 
-  RepoSequence(
+  private RepoSequence(
       GitRepositoryManager repoManager,
       GitReferenceUpdated gitRefUpdated,
       Project.NameKey projectName,
@@ -201,6 +281,7 @@
    *
    * @return the next available sequence number
    */
+  @Override
   public int next() {
     return Iterables.getOnlyElement(next(1));
   }
@@ -213,6 +294,7 @@
    * @param count the number of sequence numbers which should be returned
    * @return the next N available sequence numbers
    */
+  @Override
   public ImmutableList<Integer> next(int count) {
     if (count == 0) {
       return ImmutableList.of();
@@ -295,12 +377,7 @@
     }
   }
 
-  public static ReceiveCommand storeNew(ObjectInserter ins, String name, int val)
-      throws IOException {
-    ObjectId newId = ins.insert(OBJ_BLOB, Integer.toString(val).getBytes(UTF_8));
-    return new ReceiveCommand(ObjectId.zeroId(), newId, RefNames.REFS_SEQUENCES + name);
-  }
-
+  @Override
   public void storeNew(int value) {
     counterLock.lock();
     try (Repository repo = repoManager.openRepository(projectName);
@@ -326,6 +403,12 @@
     }
   }
 
+  @Override
+  public int getBatchSize() {
+    return batchSize;
+  }
+
+  @Override
   public int current() {
     counterLock.lock();
     try (Repository repo = repoManager.openRepository(projectName);
@@ -350,6 +433,7 @@
    *
    * <p>Explicitly calls {@link #next()} if this instance didn't return sequence number until now.
    */
+  @Override
   public int last() {
     if (counter == 0) {
       next();
diff --git a/java/com/google/gerrit/server/notedb/Sequences.java b/java/com/google/gerrit/server/notedb/Sequences.java
deleted file mode 100644
index b42253e..0000000
--- a/java/com/google/gerrit/server/notedb/Sequences.java
+++ /dev/null
@@ -1,199 +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.notedb;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.metrics.Description;
-import com.google.gerrit.metrics.Description.Units;
-import com.google.gerrit.metrics.Field;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.metrics.Timer2;
-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.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.logging.Metadata;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import org.eclipse.jgit.lib.Config;
-
-@Singleton
-public class Sequences {
-  private static final String SECTION_NOTEDB = "noteDb";
-  private static final String KEY_SEQUENCE_BATCH_SIZE = "sequenceBatchSize";
-  private static final int DEFAULT_ACCOUNTS_SEQUENCE_BATCH_SIZE = 1;
-  private static final int DEFAULT_CHANGES_SEQUENCE_BATCH_SIZE = 20;
-
-  public static final String NAME_ACCOUNTS = "accounts";
-  public static final String NAME_GROUPS = "groups";
-  public static final String NAME_CHANGES = "changes";
-
-  public static final int FIRST_ACCOUNT_ID = 1000000;
-  public static final int FIRST_GROUP_ID = 1;
-  public static final int FIRST_CHANGE_ID = 1;
-
-  private enum SequenceType {
-    ACCOUNTS,
-    CHANGES,
-    GROUPS;
-  }
-
-  private final RepoSequence accountSeq;
-  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(
-      @GerritServerConfig Config cfg,
-      GitRepositoryManager repoManager,
-      GitReferenceUpdated gitRefUpdated,
-      AllProjectsName allProjects,
-      AllUsersName allUsers,
-      MetricMaker metrics) {
-
-    accountBatchSize =
-        cfg.getInt(
-            SECTION_NOTEDB,
-            NAME_ACCOUNTS,
-            KEY_SEQUENCE_BATCH_SIZE,
-            DEFAULT_ACCOUNTS_SEQUENCE_BATCH_SIZE);
-    accountSeq =
-        new RepoSequence(
-            repoManager,
-            gitRefUpdated,
-            allUsers,
-            NAME_ACCOUNTS,
-            () -> FIRST_ACCOUNT_ID,
-            accountBatchSize);
-
-    changeBatchSize =
-        cfg.getInt(
-            SECTION_NOTEDB,
-            NAME_CHANGES,
-            KEY_SEQUENCE_BATCH_SIZE,
-            DEFAULT_CHANGES_SEQUENCE_BATCH_SIZE);
-    changeSeq =
-        new RepoSequence(
-            repoManager,
-            gitRefUpdated,
-            allProjects,
-            NAME_CHANGES,
-            () -> FIRST_CHANGE_ID,
-            changeBatchSize);
-
-    groupSeq =
-        new RepoSequence(
-            repoManager,
-            gitRefUpdated,
-            allUsers,
-            NAME_GROUPS,
-            () -> FIRST_GROUP_ID,
-            groupBatchSize);
-
-    nextIdLatency =
-        metrics.newTimer(
-            "sequence/next_id_latency",
-            new Description("Latency of requesting IDs from repo sequences")
-                .setCumulative()
-                .setUnit(Units.MILLISECONDS),
-            Field.ofEnum(SequenceType.class, "sequence", Metadata.Builder::noteDbSequenceType)
-                .description("The sequence from which IDs were retrieved.")
-                .build(),
-            Field.ofBoolean("multiple", Metadata.Builder::multiple)
-                .description("Whether more than one ID was retrieved.")
-                .build());
-  }
-
-  public int nextAccountId() {
-    try (Timer2.Context<SequenceType, Boolean> timer =
-        nextIdLatency.start(SequenceType.ACCOUNTS, false)) {
-      return accountSeq.next();
-    }
-  }
-
-  public int nextChangeId() {
-    try (Timer2.Context<SequenceType, Boolean> timer =
-        nextIdLatency.start(SequenceType.CHANGES, false)) {
-      return changeSeq.next();
-    }
-  }
-
-  public ImmutableList<Integer> nextChangeIds(int count) {
-    try (Timer2.Context<SequenceType, Boolean> timer =
-        nextIdLatency.start(SequenceType.CHANGES, count > 1)) {
-      return changeSeq.next(count);
-    }
-  }
-
-  public int nextGroupId() {
-    try (Timer2.Context<SequenceType, Boolean> timer =
-        nextIdLatency.start(SequenceType.GROUPS, false)) {
-      return groupSeq.next();
-    }
-  }
-
-  public int changeBatchSize() {
-    return changeBatchSize;
-  }
-
-  public int groupBatchSize() {
-    return groupBatchSize;
-  }
-
-  public int accountBatchSize() {
-    return accountBatchSize;
-  }
-
-  public int currentChangeId() {
-    return changeSeq.current();
-  }
-
-  public int currentAccountId() {
-    return accountSeq.current();
-  }
-
-  public int currentGroupId() {
-    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);
-  }
-
-  public void setAccountIdValue(int value) {
-    accountSeq.storeNew(value);
-  }
-
-  public void setGroupIdValue(int value) {
-    groupSeq.storeNew(value);
-  }
-}
diff --git a/java/com/google/gerrit/server/notedb/StarredChangesUtilNoteDbImpl.java b/java/com/google/gerrit/server/notedb/StarredChangesUtilNoteDbImpl.java
new file mode 100644
index 0000000..27a441b
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/StarredChangesUtilNoteDbImpl.java
@@ -0,0 +1,335 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+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.Change;
+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.GerritPersonIdent;
+import com.google.gerrit.server.StarredChangesUtil;
+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.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+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.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+@Singleton
+public class StarredChangesUtilNoteDbImpl implements StarredChangesUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final String DEFAULT_STAR_LABEL = "star";
+
+  private final GitRepositoryManager repoManager;
+  private final GitReferenceUpdated gitRefUpdated;
+  private final AllUsersName allUsers;
+  private final Provider<PersonIdent> serverIdent;
+
+  @Inject
+  StarredChangesUtilNoteDbImpl(
+      GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
+      AllUsersName allUsers,
+      @GerritPersonIdent Provider<PersonIdent> serverIdent) {
+    this.repoManager = repoManager;
+    this.gitRefUpdated = gitRefUpdated;
+    this.allUsers = allUsers;
+    this.serverIdent = serverIdent;
+  }
+
+  @Override
+  public boolean isStarred(Account.Id accountId, Change.Id virtualId) {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return getStarRef(repo, RefNames.refsStarredChanges(virtualId, accountId)).isPresent();
+    } catch (IOException e) {
+      throw new StorageException(
+          String.format(
+              "Reading stars from change %d for account %d failed",
+              virtualId.get(), accountId.get()),
+          e);
+    }
+  }
+
+  @Override
+  public void star(Account.Id accountId, Change.Id virtualId) {
+    updateStar(accountId, virtualId, true);
+  }
+
+  @Override
+  public void unstar(Account.Id accountId, Change.Id virtualId) {
+    updateStar(accountId, virtualId, false);
+  }
+
+  private void updateStar(Account.Id accountId, Change.Id virtualId, boolean shouldAdd) {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      String refName = RefNames.refsStarredChanges(virtualId, accountId);
+      if (shouldAdd) {
+        addRef(repo, refName, null);
+      } else {
+        Optional<Ref> ref = getStarRef(repo, refName);
+        if (ref.isPresent()) {
+          deleteRef(repo, refName, ref.get().getObjectId());
+        }
+      }
+    } catch (IOException e) {
+      throw new StorageException(
+          String.format("Star change %d for account %d failed", virtualId.get(), accountId.get()),
+          e);
+    }
+  }
+
+  @Override
+  public Set<Change.Id> areStarred(
+      Repository allUsersRepo, List<Change.Id> virtualIds, Account.Id caller) {
+    List<String> starRefs =
+        virtualIds.stream()
+            .map(c -> RefNames.refsStarredChanges(c, caller))
+            .collect(Collectors.toList());
+    try {
+      return allUsersRepo
+          .getRefDatabase()
+          .exactRef(starRefs.toArray(new String[0]))
+          .keySet()
+          .stream()
+          .map(r -> Change.Id.fromAllUsersRef(r))
+          .collect(Collectors.toSet());
+    } catch (IOException e) {
+      logger.atWarning().withCause(e).log(
+          "Failed getting starred changes for account %d within changes: %s",
+          caller.get(), Joiner.on(", ").join(virtualIds));
+      return ImmutableSet.of();
+    }
+  }
+
+  @Override
+  public void unstarAllForChangeDeletion(Change.Id virtualId) throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      BatchRefUpdate batchUpdate = repo.getRefDatabase().newBatchUpdate();
+      batchUpdate.setAllowNonFastForwards(true);
+      batchUpdate.setRefLogIdent(serverIdent.get());
+      batchUpdate.setRefLogMessage("Unstar change " + virtualId.get(), true);
+      for (Account.Id accountId : getStars(repo, virtualId)) {
+        String refName = RefNames.refsStarredChanges(virtualId, accountId);
+        Ref ref = repo.getRefDatabase().exactRef(refName);
+        if (ref != null) {
+          batchUpdate.addCommand(new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), refName));
+        }
+      }
+      batchUpdate.execute(rw, NullProgressMonitor.INSTANCE);
+      for (ReceiveCommand command : batchUpdate.getCommands()) {
+        if (command.getResult() != ReceiveCommand.Result.OK) {
+          String message =
+              String.format(
+                  "Unstar change %d failed, ref %s could not be deleted: %s",
+                  virtualId.get(), command.getRefName(), command.getResult());
+          if (command.getResult() == ReceiveCommand.Result.LOCK_FAILURE) {
+            throw new LockFailureException(message, batchUpdate);
+          }
+          throw new GitUpdateFailureException(message, batchUpdate);
+        }
+      }
+    }
+  }
+
+  @Override
+  public ImmutableMap<Account.Id, Ref> byChange(Change.Id virtualId) {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      ImmutableMap.Builder<Account.Id, Ref> builder = ImmutableMap.builder();
+      for (Account.Id accountId : getStars(repo, virtualId)) {
+        Optional<Ref> starRef = getStarRef(repo, RefNames.refsStarredChanges(virtualId, accountId));
+        if (starRef.isPresent()) {
+          builder.put(accountId, starRef.get());
+        }
+      }
+      return builder.build();
+    } catch (IOException e) {
+      throw new StorageException(
+          String.format("Get accounts that starred change %d failed", virtualId.get()), e);
+    }
+  }
+
+  @Override
+  public ImmutableSet<Change.Id> byAccountId(Account.Id accountId) {
+    return byAccountId(accountId, true);
+  }
+
+  @Override
+  public ImmutableSet<Change.Id> byAccountId(Account.Id accountId, boolean skipInvalidChanges) {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      ImmutableSet.Builder<Change.Id> builder = ImmutableSet.builder();
+      for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_STARRED_CHANGES)) {
+        Account.Id currentAccountId = Account.Id.fromRef(ref.getName());
+        // Skip all refs that don't correspond with accountId.
+        if (currentAccountId == null || !currentAccountId.equals(accountId)) {
+          continue;
+        }
+
+        // Skip invalid change ids.
+        Change.Id changeId = Change.Id.fromAllUsersRef(ref.getName());
+        if (skipInvalidChanges && changeId == null) {
+          continue;
+        }
+        builder.add(changeId);
+      }
+      return builder.build();
+    } catch (IOException e) {
+      throw new StorageException(
+          String.format("Get starred changes for account %d failed", accountId.get()), e);
+    }
+  }
+
+  private static Set<Account.Id> getStars(Repository allUsers, Change.Id virtualId)
+      throws IOException {
+    String prefix = RefNames.refsStarredChangesPrefix(virtualId);
+    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());
+  }
+
+  private static Optional<Ref> getStarRef(Repository repo, @Nullable String refName)
+      throws IOException {
+    if (refName == null) {
+      return Optional.empty();
+    }
+    Ref ref = repo.exactRef(refName);
+    return Optional.ofNullable(ref);
+  }
+
+  private static ObjectId writeStarredRefContent(Repository repo) throws IOException {
+    try (ObjectInserter oi = repo.newObjectInserter()) {
+      ObjectId id = oi.insert(Constants.OBJ_BLOB, DEFAULT_STAR_LABEL.getBytes(UTF_8));
+      oi.flush();
+      return id;
+    }
+  }
+
+  private void addRef(Repository repo, String refName, ObjectId oldObjectId) throws IOException {
+    try (TraceTimer traceTimer =
+            TraceContext.newTimer(
+                "Add star ref",
+                Metadata.builder().noteDbRefName(refName).resourceCount(1).build());
+        RevWalk rw = new RevWalk(repo)) {
+      RefUpdate u = repo.updateRef(refName);
+      u.setExpectedOldObjectId(oldObjectId);
+      u.setForceUpdate(true);
+      u.setNewObjectId(writeStarredRefContent(repo));
+      u.setRefLogIdent(serverIdent.get());
+      u.setRefLogMessage("Add star ref", true);
+      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+        RefUpdate.Result result = u.update(rw);
+        switch (result) {
+          case NEW:
+          case FORCED:
+          case NO_CHANGE:
+          case FAST_FORWARD:
+            gitRefUpdated.fire(allUsers, u, null);
+            return;
+          case LOCK_FAILURE:
+            throw new LockFailureException(
+                String.format("Add star ref on ref %s failed", refName), u);
+          case IO_FAILURE:
+          case NOT_ATTEMPTED:
+          case REJECTED:
+          case REJECTED_CURRENT_BRANCH:
+          case RENAMED:
+          case REJECTED_MISSING_OBJECT:
+          case REJECTED_OTHER_REASON:
+          default:
+            throw new StorageException(
+                String.format("Add star ref on ref %s failed: %s", refName, result.name()));
+        }
+      }
+    }
+  }
+
+  private void deleteRef(Repository repo, String refName, ObjectId oldObjectId) throws IOException {
+    if (ObjectId.zeroId().equals(oldObjectId)) {
+      // ref doesn't exist
+      return;
+    }
+
+    try (TraceTimer traceTimer =
+        TraceContext.newTimer(
+            "Delete star ref", Metadata.builder().noteDbRefName(refName).build())) {
+      RefUpdate u = repo.updateRef(refName);
+      u.setForceUpdate(true);
+      u.setExpectedOldObjectId(oldObjectId);
+      u.setRefLogIdent(serverIdent.get());
+      u.setRefLogMessage("Unstar change", true);
+      try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+        RefUpdate.Result result = u.delete();
+        switch (result) {
+          case FORCED:
+            gitRefUpdated.fire(allUsers, u, null);
+            return;
+          case LOCK_FAILURE:
+            throw new LockFailureException(String.format("Delete star ref %s failed", refName), u);
+          case NEW:
+          case NO_CHANGE:
+          case FAST_FORWARD:
+          case IO_FAILURE:
+          case NOT_ATTEMPTED:
+          case REJECTED:
+          case REJECTED_CURRENT_BRANCH:
+          case RENAMED:
+          case REJECTED_MISSING_OBJECT:
+          case REJECTED_OTHER_REASON:
+          default:
+            throw new StorageException(
+                String.format("Delete star ref %s failed: %s", refName, result.name()));
+        }
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index 0818f23..e27faf6 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -83,6 +83,10 @@
     return cfg.getBoolean("change", null, "cacheAutomerge", true);
   }
 
+  public static boolean diff3ConflictView(Config cfg) {
+    return cfg.getBoolean("change", null, "diff3ConflictView", false);
+  }
+
   private enum OperationType {
     CACHE_LOAD,
     IN_MEMORY_WRITE,
@@ -93,6 +97,7 @@
   private final Timer1<OperationType> latency;
   private final Provider<PersonIdent> gerritIdentProvider;
   private final boolean save;
+  private final boolean useDiff3;
   private final ThreeWayMergeStrategy configuredMergeStrategy;
 
   @Inject
@@ -117,6 +122,7 @@
                 .setUnit("milliseconds"),
             operationTypeField);
     this.save = cacheAutomerge(cfg);
+    this.useDiff3 = diff3ConflictView(cfg);
     this.gerritIdentProvider = gerritIdentProvider;
     this.configuredMergeStrategy = MergeUtil.getMergeStrategy(cfg);
   }
@@ -254,7 +260,8 @@
               merge.getParent(0),
               "BRANCH",
               merge.getParent(1),
-              m.getMergeResults());
+              m.getMergeResults(),
+              useDiff3);
     }
     logger.atFine().log("AutoMerge treeId=%s", treeId.name());
 
diff --git a/java/com/google/gerrit/server/patch/DiffExecutorModule.java b/java/com/google/gerrit/server/patch/DiffExecutorModule.java
index b9e644f..9c4c7e5 100644
--- a/java/com/google/gerrit/server/patch/DiffExecutorModule.java
+++ b/java/com/google/gerrit/server/patch/DiffExecutorModule.java
@@ -23,6 +23,7 @@
 import java.util.concurrent.Executors;
 
 /** Module providing the {@link DiffExecutor}. */
+@SuppressWarnings("ProvidesMethodOutsideOfModule")
 public class DiffExecutorModule extends AbstractModule {
 
   @Override
diff --git a/java/com/google/gerrit/server/patch/DiffFileSizeValidator.java b/java/com/google/gerrit/server/patch/DiffFileSizeValidator.java
new file mode 100644
index 0000000..6964d63
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffFileSizeValidator.java
@@ -0,0 +1,67 @@
+// 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.patch;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.entities.Patch.PatchType;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.LargeObjectException;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class DiffFileSizeValidator implements DiffValidator {
+  static final int DEFAULT_MAX_FILE_SIZE = 0;
+  private static final String ERROR_MESSAGE =
+      "File size for file %s exceeded the max file size threshold. Threshold = %d MiB, "
+          + "Actual size = %d MiB";
+
+  @VisibleForTesting int maxFileSize;
+
+  @VisibleForTesting
+  void setMaxFileSize(int threshold) {
+    this.maxFileSize = threshold;
+  }
+
+  @Inject
+  public DiffFileSizeValidator(@GerritServerConfig Config cfg) {
+    this.maxFileSize = cfg.getInt("change", "maxFileSizeDiff", DEFAULT_MAX_FILE_SIZE);
+  }
+
+  @Override
+  public void validate(FileDiffOutput fileDiff) throws LargeObjectException {
+    if (maxFileSize <= 0
+        || (fileDiff.patchType().isPresent()
+            && fileDiff.patchType().get().equals(PatchType.BINARY))) {
+      // Do not apply limits if the config is not set.
+      // Also do not check file size for binary files. For modified binary files, JGit skips the
+      // diff and returns no edits. On the API layer, we only set the DiffInfo.ContentEntry.skip
+      // parameter to the number of lines in the file and the front-end displays a "Difference in
+      // binary files" in the diff view.
+      return;
+    }
+    if (fileDiff.size() > maxFileSize) {
+      throw new LargeObjectException(
+          String.format(ERROR_MESSAGE, fileDiff.getDefaultPath(), maxFileSize, fileDiff.size()));
+    }
+    if (fileDiff.sizeDelta() > maxFileSize) {
+      throw new LargeObjectException(
+          String.format(
+              ERROR_MESSAGE, fileDiff.getDefaultPath(), maxFileSize, fileDiff.sizeDelta()));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/DiffUtil.java b/java/com/google/gerrit/server/patch/DiffUtil.java
index 115830e..022614a 100644
--- a/java/com/google/gerrit/server/patch/DiffUtil.java
+++ b/java/com/google/gerrit/server/patch/DiffUtil.java
@@ -168,13 +168,14 @@
     }
   }
 
-  public static String cleanPatch(final String patch) {
+  public static String normalizePatchForComparison(final String patch) {
     String res = removePatchHeader(patch);
     return res
-        // Remove "index NN..NN" lines
-        .replaceAll("(?m)^index.*", "")
-        // Remove hunk-headers lines
-        .replaceAll("(?m)^@@ .*", "")
+        // Remove any lines which are not diff lines or file header lines - such index,
+        // hunk-headers, and context lines.
+        .replaceAll("(?m)^[^+-].*", "")
+        .replaceAll("(?m)^[+]{3} [ab]/", "+++ ")
+        .replaceAll("(?m)^-{3} [ab]/", "--- ")
         // Remove empty lines
         .replaceAll("\n+", "\n")
         // Trim
@@ -184,21 +185,21 @@
   public static String removePatchHeader(final String patch) {
     String res = patch.trim();
     if (!res.startsWith("diff --") && res.contains("\ndiff --")) {
-      return res.substring(patch.indexOf("\ndiff --"), patch.length() - 1);
+      return res.substring(res.indexOf("\ndiff --"));
     }
     return res;
   }
 
   public static Optional<String> getPatchHeader(final String patch) {
-    if (patch.startsWith("diff --")) {
+    String res = patch.trim();
+    if (res.startsWith("diff ")) {
       return Optional.empty();
     }
-    return Optional.ofNullable(
-        Strings.emptyToNull(patch.trim().substring(0, patch.indexOf("\ndiff --git"))));
+    return Optional.ofNullable(Strings.emptyToNull(res.substring(0, res.indexOf("\ndiff "))));
   }
 
-  public static String cleanPatch(BinaryResult bin) throws IOException {
-    return cleanPatch(bin.asString());
+  public static String normalizePatchForComparison(BinaryResult bin) throws IOException {
+    return normalizePatchForComparison(bin.asString());
   }
 
   private static boolean isRootOrMergeCommit(RevCommit commit) {
diff --git a/java/com/google/gerrit/server/patch/DiffValidator.java b/java/com/google/gerrit/server/patch/DiffValidator.java
new file mode 100644
index 0000000..aee3c8b
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffValidator.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.patch;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.server.git.LargeObjectException;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+
+/** Interface to validate diff outputs. */
+@ExtensionPoint
+public interface DiffValidator {
+  void validate(FileDiffOutput fileDiffOutput)
+      throws LargeObjectException, DiffNotAvailableException;
+}
diff --git a/java/com/google/gerrit/server/patch/DiffValidators.java b/java/com/google/gerrit/server/patch/DiffValidators.java
new file mode 100644
index 0000000..964353d
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffValidators.java
@@ -0,0 +1,37 @@
+// 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.patch;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.git.LargeObjectException;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.inject.Inject;
+
+/** Validates {@link FileDiffOutput}(s) after they are computed by the {@link DiffOperations}. */
+public class DiffValidators {
+  DynamicSet<DiffValidator> diffValidators;
+
+  @Inject
+  public DiffValidators(DynamicSet<DiffValidator> diffValidators) {
+    this.diffValidators = diffValidators;
+  }
+
+  public void validate(FileDiffOutput fileDiffOutput)
+      throws LargeObjectException, DiffNotAvailableException {
+    for (DiffValidator validator : diffValidators) {
+      validator.validate(fileDiffOutput);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/GitPositionTransformer.java b/java/com/google/gerrit/server/patch/GitPositionTransformer.java
index f33d302..880037b 100644
--- a/java/com/google/gerrit/server/patch/GitPositionTransformer.java
+++ b/java/com/google/gerrit/server/patch/GitPositionTransformer.java
@@ -139,8 +139,10 @@
     Set<String> newFiles = newFilesPerOldFile.get(oldFilePath);
     if (newFiles.isEmpty()) {
       // File was deleted.
-      return Streams.stream(
-          positionConflictStrategy.getOnFileConflict(entity.position()).map(entity::withPosition));
+      return positionConflictStrategy
+          .getOnFileConflict(entity.position())
+          .map(entity::withPosition)
+          .stream();
     }
     return newFiles.stream().map(entity::withFilePath);
   }
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 32a62c8..08af714 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -94,6 +94,7 @@
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
   private final DiffOperations diffOperations;
+  private final DiffValidators diffValidators;
 
   private final Change.Id changeId;
 
@@ -109,6 +110,7 @@
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
       DiffOperations diffOperations,
+      DiffValidators diffValidators,
       @Assisted ChangeNotes notes,
       @Assisted String fileName,
       @Assisted("patchSetA") @Nullable PatchSet.Id patchSetA,
@@ -124,6 +126,7 @@
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
     this.diffOperations = diffOperations;
+    this.diffValidators = diffValidators;
 
     this.fileName = fileName;
     this.psa = patchSetA;
@@ -144,6 +147,7 @@
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
       DiffOperations diffOperations,
+      DiffValidators diffValidators,
       @Assisted ChangeNotes notes,
       @Assisted String fileName,
       @Assisted int parentNum,
@@ -159,6 +163,7 @@
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
     this.diffOperations = diffOperations;
+    this.diffValidators = diffValidators;
 
     this.fileName = fileName;
     this.psa = null;
@@ -223,13 +228,14 @@
   }
 
   private PatchScript getPatchScript(Repository git, ObjectId aId, ObjectId bId)
-      throws IOException, DiffNotAvailableException {
+      throws IOException, DiffNotAvailableException, LargeObjectException {
     FileDiffOutput fileDiffOutput =
         aId == null
             ? diffOperations.getModifiedFileAgainstParent(
                 notes.getProjectName(), bId, parentNum, fileName, diffPrefs.ignoreWhitespace)
             : diffOperations.getModifiedFile(
                 notes.getProjectName(), aId, bId, fileName, diffPrefs.ignoreWhitespace);
+    diffValidators.validate(fileDiffOutput);
     return newBuilder().toPatchScript(git, fileDiffOutput);
   }
 
diff --git a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
index 1329e1f..ab9e6f9 100644
--- a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
+++ b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
@@ -112,20 +112,22 @@
           InvalidChangeOperationException {
     PatchSet currentPatchset = notes.getCurrentPatchSet();
 
-    PatchSet.Id latestApprovedPatchsetId = getLatestApprovedPatchsetId(notes);
-    if (latestApprovedPatchsetId.get() == currentPatchset.id().get()) {
+    Optional<PatchSet.Id> latestApprovedPatchsetId = getLatestApprovedPatchsetId(notes);
+    if (latestApprovedPatchsetId.isEmpty()
+        || latestApprovedPatchsetId.get().get() == currentPatchset.id().get()) {
       // If the latest approved patchset is the current patchset, no need to return anything.
       return "";
     }
     StringBuilder diff =
         new StringBuilder(
             String.format(
-                "\n\n%d is the latest approved patch-set.\n", latestApprovedPatchsetId.get()));
+                "\n\n%d is the latest approved patch-set.\n",
+                latestApprovedPatchsetId.get().get()));
     Map<String, FileDiffOutput> modifiedFiles =
         listModifiedFiles(
             notes.getProjectName(),
             currentPatchset,
-            notes.getPatchSets().get(latestApprovedPatchsetId));
+            notes.getPatchSets().get(latestApprovedPatchsetId.get()));
 
     // To make the message a bit more concise, we skip the magic files.
     List<FileDiffOutput> modifiedFilesList =
@@ -175,7 +177,7 @@
             getDiffForFile(
                 notes,
                 currentPatchset.id(),
-                latestApprovedPatchsetId,
+                latestApprovedPatchsetId.get(),
                 fileDiff,
                 currentUser,
                 formatterResult,
@@ -294,10 +296,10 @@
     return diffPreferencesInfo;
   }
 
-  private PatchSet.Id getLatestApprovedPatchsetId(ChangeNotes notes) {
+  private Optional<PatchSet.Id> getLatestApprovedPatchsetId(ChangeNotes notes) {
     ProjectState projectState =
         projectCache.get(notes.getProjectName()).orElseThrow(illegalState(notes.getProjectName()));
-    PatchSet.Id maxPatchSetId = PatchSet.id(notes.getChangeId(), 1);
+    Optional<PatchSet.Id> maxPatchSetId = Optional.empty();
     for (PatchSetApproval patchSetApproval : notes.getApprovals().onlyNonCopied().values()) {
       if (!patchSetApproval.label().equals(LabelId.CODE_REVIEW)) {
         continue;
@@ -307,8 +309,9 @@
       if (!lt.isPresent() || !lt.get().isMaxPositive(patchSetApproval)) {
         continue;
       }
-      if (patchSetApproval.patchSetId().get() > maxPatchSetId.get()) {
-        maxPatchSetId = patchSetApproval.patchSetId();
+      if (maxPatchSetId.isEmpty()
+          || patchSetApproval.patchSetId().get() > maxPatchSetId.get().get()) {
+        maxPatchSetId = Optional.of(patchSetApproval.patchSetId());
       }
     }
     return maxPatchSetId;
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
index 92a0ace..b7cd5e4 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
@@ -64,6 +64,10 @@
    */
   public abstract Optional<String> newPath();
 
+  public String getDefaultPath() {
+    return oldPath().isPresent() ? oldPath().get() : newPath().get();
+  }
+
   /**
    * The file mode of the old file at the old git tree diff identified by {@link #oldCommitId()}
    * ()}.
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index db15da80..664ffa2 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -17,8 +17,10 @@
 import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.ON_BEHALF_OF;
 import static com.google.gerrit.server.permissions.DefaultPermissionMappings.labelPermissionName;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Permission;
@@ -36,6 +38,8 @@
 
 /** Access control management for a user accessing a single change. */
 class ChangeControl {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final RefControl refControl;
   private final ChangeData changeData;
 
@@ -136,7 +140,7 @@
   /** Is this user a reviewer for the change? */
   private boolean isReviewer(ChangeData cd) {
     if (getUser().isIdentifiedUser()) {
-      Collection<Account.Id> results = cd.reviewers().all();
+      ImmutableSet<Account.Id> results = cd.reviewers().all();
       return results.contains(getUser().getAccountId());
     }
     return false;
@@ -184,11 +188,43 @@
         || getProjectControl().isAdmin();
   }
 
+  /** Can this user edit the custom keyed values? */
+  private boolean canEditCustomKeyedValues() {
+    return isOwner() // owner (aka creator) of the change can edit custom keyed values
+        || getProjectControl().isAdmin();
+  }
+
   private boolean isPrivateVisible(ChangeData cd) {
-    return isOwner()
-        || isReviewer(cd)
-        || refControl.canPerform(Permission.VIEW_PRIVATE_CHANGES)
-        || getUser().isInternalUser();
+    if (isOwner()) {
+      logger.atFine().log(
+          "%s can see private change %s because this user is the change owner",
+          getUser().getLoggableName(), cd.getId());
+      return true;
+    }
+
+    if (isReviewer(cd)) {
+      logger.atFine().log(
+          "%s can see private change %s because this user is a reviewer",
+          getUser().getLoggableName(), cd.getId());
+      return true;
+    }
+
+    if (refControl.canPerform(Permission.VIEW_PRIVATE_CHANGES)) {
+      logger.atFine().log(
+          "%s can see private change %s because this user can view private changes",
+          getUser().getLoggableName(), cd.getId());
+      return true;
+    }
+
+    if (getUser().isInternalUser()) {
+      logger.atFine().log(
+          "%s can see private change %s because this user is an internal user",
+          getUser().getLoggableName(), cd.getId());
+      return true;
+    }
+
+    logger.atFine().log("%s cannot see private change %s", getUser().getLoggableName(), cd.getId());
+    return false;
   }
 
   private class ForChangeImpl extends ForChange {
@@ -262,6 +298,8 @@
             return canEditDescription();
           case EDIT_HASHTAGS:
             return canEditHashtags();
+          case EDIT_CUSTOM_KEYED_VALUES:
+            return canEditCustomKeyedValues();
           case EDIT_TOPIC_NAME:
             return canEditTopicName();
           case REBASE:
diff --git a/java/com/google/gerrit/server/permissions/ChangePermission.java b/java/com/google/gerrit/server/permissions/ChangePermission.java
index 7741adac..d9f83c7 100644
--- a/java/com/google/gerrit/server/permissions/ChangePermission.java
+++ b/java/com/google/gerrit/server/permissions/ChangePermission.java
@@ -38,6 +38,7 @@
    */
   ABANDON,
   EDIT_DESCRIPTION,
+  EDIT_CUSTOM_KEYED_VALUES,
   EDIT_HASHTAGS,
   EDIT_TOPIC_NAME,
   REMOVE_REVIEWER,
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
index 958de1b..1b87446 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
@@ -91,6 +91,7 @@
       ImmutableBiMap.<ChangePermission, String>builder()
           .put(ChangePermission.READ, Permission.READ)
           .put(ChangePermission.ABANDON, Permission.ABANDON)
+          .put(ChangePermission.EDIT_CUSTOM_KEYED_VALUES, Permission.EDIT_CUSTOM_KEYED_VALUES)
           .put(ChangePermission.EDIT_HASHTAGS, Permission.EDIT_HASHTAGS)
           .put(ChangePermission.EDIT_TOPIC_NAME, Permission.EDIT_TOPIC_NAME)
           .put(ChangePermission.REMOVE_REVIEWER, Permission.REMOVE_REVIEWER)
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index 7c4d7e7..153fd59 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -445,12 +445,15 @@
         if (logger.atFine().isEnabled() || LoggingContext.getInstance().isAclLogging()) {
           String logMessage =
               String.format(
-                  "'%s' can perform '%s' with force=%s on project '%s' for ref '%s'",
+                  "'%s' can perform '%s' with force=%s on project '%s' for ref '%s'"
+                      + " (allowed for group '%s' by rule '%s')",
                   getUser().getLoggableName(),
                   permissionName,
                   withForce,
                   projectControl.getProject().getName(),
-                  refName);
+                  refName,
+                  pr.getGroup().getUUID().get(),
+                  pr);
           LoggingContext.getInstance().addAclLogRecord(logMessage);
           logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
         }
diff --git a/java/com/google/gerrit/server/plugins/CopyConfigModule.java b/java/com/google/gerrit/server/plugins/CopyConfigModule.java
index 9b74341..c495721 100644
--- a/java/com/google/gerrit/server/plugins/CopyConfigModule.java
+++ b/java/com/google/gerrit/server/plugins/CopyConfigModule.java
@@ -41,6 +41,7 @@
  * dbInjector that are not otherwise easily available, but that a plugin author might expect to
  * exist.
  */
+@SuppressWarnings("ProvidesMethodOutsideOfModule")
 @Singleton
 class CopyConfigModule extends AbstractModule {
   @Inject @SitePath private Path sitePath;
diff --git a/java/com/google/gerrit/server/plugins/DisablePlugin.java b/java/com/google/gerrit/server/plugins/DisablePlugin.java
index 9e238f8..d845ec17 100644
--- a/java/com/google/gerrit/server/plugins/DisablePlugin.java
+++ b/java/com/google/gerrit/server/plugins/DisablePlugin.java
@@ -53,7 +53,12 @@
     if (mandatoryPluginsCollection.contains(name)) {
       throw new MethodNotAllowedException("Plugin " + name + " is mandatory");
     }
-    loader.disablePlugins(ImmutableSet.of(name));
+    try {
+      loader.disablePlugins(ImmutableSet.of(name));
+    } catch (PluginInstallException e) {
+      throw new MethodNotAllowedException("Plugin " + name + " cannot be disabled", e);
+    }
+
     return Response.ok(ListPlugins.toPluginInfo(loader.get(name)));
   }
 }
diff --git a/java/com/google/gerrit/server/plugins/JarPluginProvider.java b/java/com/google/gerrit/server/plugins/JarPluginProvider.java
index c00a69d..0a0b07e 100644
--- a/java/com/google/gerrit/server/plugins/JarPluginProvider.java
+++ b/java/com/google/gerrit/server/plugins/JarPluginProvider.java
@@ -20,7 +20,9 @@
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.plugins.Plugin.ApiType;
 import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.io.InputStream;
 import java.net.MalformedURLException;
@@ -38,6 +40,7 @@
 import java.util.jar.Manifest;
 import org.eclipse.jgit.internal.storage.file.FileSnapshot;
 
+@Singleton
 public class JarPluginProvider implements ServerPluginProvider {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -47,6 +50,8 @@
   private final Path tmpDir;
   private final PluginConfigFactory configFactory;
 
+  private ClassLoader pluginApiClassLoader = PluginUtil.parentFor(ApiType.PLUGIN);
+
   @Inject
   JarPluginProvider(SitePaths sitePaths, PluginConfigFactory configFactory) {
     this.tmpDir = sitePaths.tmp_dir;
@@ -134,9 +139,14 @@
       }
       urls.add(tmp.toUri().toURL());
 
-      ClassLoader pluginLoader =
-          URLClassLoader.newInstance(
-              urls.toArray(new URL[urls.size()]), PluginUtil.parentFor(type));
+      ClassLoader parentClassLoader = parentFor(type);
+
+      URLClassLoader pluginLoader =
+          URLClassLoader.newInstance(urls.toArray(new URL[urls.size()]), parentClassLoader);
+
+      if (manifest.getMainAttributes().getValue(ServerPlugin.API_MODULE) != null) {
+        pluginApiClassLoader = pluginLoader;
+      }
 
       JarScanner jarScanner = createJarScanner(tmp);
       PluginConfig pluginConfig = configFactory.getFromGerritConfig(name);
@@ -163,6 +173,17 @@
     }
   }
 
+  private ClassLoader parentFor(ApiType type) {
+    switch (type) {
+      case PLUGIN:
+        return pluginApiClassLoader;
+
+      // $CASES-OMITTED$
+      default:
+        return PluginUtil.parentFor(type);
+    }
+  }
+
   private JarScanner createJarScanner(Path srcJar) throws InvalidPluginException {
     try {
       return new JarScanner(srcJar);
diff --git a/java/com/google/gerrit/server/plugins/JsPlugin.java b/java/com/google/gerrit/server/plugins/JsPlugin.java
index c120cdd..2c4edec 100644
--- a/java/com/google/gerrit/server/plugins/JsPlugin.java
+++ b/java/com/google/gerrit/server/plugins/JsPlugin.java
@@ -106,4 +106,9 @@
   public PluginContentScanner getContentScanner() {
     return PluginContentScanner.EMPTY;
   }
+
+  @Override
+  public Injector getApiInjector() {
+    return null;
+  }
 }
diff --git a/java/com/google/gerrit/server/plugins/Plugin.java b/java/com/google/gerrit/server/plugins/Plugin.java
index 55add35..3de7e27 100644
--- a/java/com/google/gerrit/server/plugins/Plugin.java
+++ b/java/com/google/gerrit/server/plugins/Plugin.java
@@ -21,10 +21,12 @@
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.server.PluginUser;
 import com.google.inject.Injector;
+import com.google.inject.Module;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Optional;
 import java.util.jar.Attributes;
 import java.util.jar.Manifest;
 import org.eclipse.jgit.internal.storage.file.FileSnapshot;
@@ -143,6 +145,9 @@
   @Nullable
   public abstract Injector getHttpInjector();
 
+  @Nullable
+  public abstract Injector getApiInjector();
+
   public void add(RegistrationHandle handle) {
     if (manager != null) {
       if (handle instanceof ReloadableRegistrationHandle) {
@@ -172,4 +177,8 @@
   boolean isModified(Path jar) {
     return snapshot.isModified(jar.toFile());
   }
+
+  public Optional<Module> getApiModule() {
+    return Optional.empty();
+  }
 }
diff --git a/java/com/google/gerrit/server/plugins/PluginCleanerTask.java b/java/com/google/gerrit/server/plugins/PluginCleanerTask.java
index 6919bbc..5925148 100644
--- a/java/com/google/gerrit/server/plugins/PluginCleanerTask.java
+++ b/java/com/google/gerrit/server/plugins/PluginCleanerTask.java
@@ -41,15 +41,6 @@
 
   @Override
   public void run() {
-    try {
-      for (int t = 0; t < 2 * (attempts + 1); t++) {
-        System.gc();
-        Thread.sleep(50);
-      }
-    } catch (InterruptedException e) {
-      // Ignored
-    }
-
     int left = loader.processPendingCleanups();
     synchronized (this) {
       pending = left;
diff --git a/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java b/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
index 87e1ca9..59e3342 100644
--- a/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
+++ b/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.extensions.registration.PrivateInternals_DynamicTypes.dynamicSetsOf;
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
@@ -60,6 +61,7 @@
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.CopyOnWriteArrayList;
 import javax.servlet.http.HttpServletRequest;
@@ -87,6 +89,7 @@
   private Module sysModule;
   private Module sshModule;
   private Module httpModule;
+  private Injector apiInjector;
 
   private Provider<ModuleGenerator> sshGen;
   private Provider<ModuleGenerator> httpGen;
@@ -94,14 +97,17 @@
   private Map<TypeLiteral<?>, DynamicItem<?>> sysItems;
   private Map<TypeLiteral<?>, DynamicItem<?>> sshItems;
   private Map<TypeLiteral<?>, DynamicItem<?>> httpItems;
+  private Map<TypeLiteral<?>, DynamicItem<?>> apiItems;
 
   private Map<TypeLiteral<?>, DynamicSet<?>> sysSets;
   private Map<TypeLiteral<?>, DynamicSet<?>> sshSets;
   private Map<TypeLiteral<?>, DynamicSet<?>> httpSets;
+  private Map<TypeLiteral<?>, DynamicSet<?>> apiSets;
 
   private Map<TypeLiteral<?>, DynamicMap<?>> sysMaps;
   private Map<TypeLiteral<?>, DynamicMap<?>> sshMaps;
   private Map<TypeLiteral<?>, DynamicMap<?>> httpMaps;
+  private Map<TypeLiteral<?>, DynamicMap<?>> apiMaps;
 
   @Inject
   PluginGuiceEnvironment(
@@ -129,6 +135,10 @@
     sysItems = dynamicItemsOf(sysInjector);
     sysSets = dynamicSetsOf(sysInjector);
     sysMaps = dynamicMapsOf(sysInjector);
+
+    apiSets = new HashMap<>();
+    apiItems = new HashMap<>();
+    apiMaps = new HashMap<>();
   }
 
   ServerInformation getServerInformation() {
@@ -258,6 +268,21 @@
       attachMap(sysMaps, plugin.getSysInjector(), plugin);
       attachMap(sshMaps, plugin.getSshInjector(), plugin);
       attachMap(httpMaps, plugin.getHttpInjector(), plugin);
+
+      apiInjector = Optional.ofNullable(plugin.getApiInjector()).orElse(apiInjector);
+
+      if (apiInjector != null) {
+        apiItems.putAll(dynamicItemsOf(apiInjector));
+        apiSets.putAll(dynamicSetsOf(apiInjector));
+        apiMaps.putAll(dynamicMapsOf(apiInjector));
+
+        List<Injector> allPluginInjectors =
+            listOfInjectors(
+                plugin.getSysInjector(), plugin.getSshInjector(), plugin.getHttpInjector());
+        allPluginInjectors.forEach(i -> attachItem(apiItems, i, plugin));
+        allPluginInjectors.forEach(i -> attachSet(apiSets, i, plugin));
+        allPluginInjectors.forEach(i -> attachMap(apiMaps, i, plugin));
+      }
     } finally {
       exit(oldContext);
     }
@@ -267,6 +292,18 @@
     }
   }
 
+  private List<Injector> listOfInjectors(Injector... injectors) {
+    ImmutableList.Builder<Injector> injectorsListBuilder = ImmutableList.builder();
+
+    for (Injector injector : injectors) {
+      if (injector != null) {
+        injectorsListBuilder.add(injector);
+      }
+    }
+
+    return injectorsListBuilder.build();
+  }
+
   public void onStopPlugin(Plugin plugin) {
     for (StopPluginListener l : onStop) {
       l.onStopPlugin(plugin);
@@ -308,17 +345,40 @@
 
     RequestContext oldContext = enter(newPlugin);
     try {
+      Optional.ofNullable(newPlugin.getApiInjector())
+          .ifPresent(i -> reattachMap(old, apiMaps, i, newPlugin));
       reattachMap(old, sysMaps, newPlugin.getSysInjector(), newPlugin);
       reattachMap(old, sshMaps, newPlugin.getSshInjector(), newPlugin);
       reattachMap(old, httpMaps, newPlugin.getHttpInjector(), newPlugin);
 
+      Optional.ofNullable(newPlugin.getApiInjector())
+          .ifPresent(i -> reattachSet(old, apiSets, i, newPlugin));
       reattachSet(old, sysSets, newPlugin.getSysInjector(), newPlugin);
       reattachSet(old, sshSets, newPlugin.getSshInjector(), newPlugin);
       reattachSet(old, httpSets, newPlugin.getHttpInjector(), newPlugin);
 
+      Optional.ofNullable(newPlugin.getApiInjector())
+          .ifPresent(i -> reattachItem(old, apiItems, i, newPlugin));
       reattachItem(old, sysItems, newPlugin.getSysInjector(), newPlugin);
       reattachItem(old, sshItems, newPlugin.getSshInjector(), newPlugin);
       reattachItem(old, httpItems, newPlugin.getHttpInjector(), newPlugin);
+
+      apiInjector = Optional.ofNullable(newPlugin.getApiInjector()).orElse(apiInjector);
+
+      if (apiInjector != null) {
+        apiItems.putAll(dynamicItemsOf(apiInjector));
+        apiSets.putAll(dynamicSetsOf(apiInjector));
+        apiMaps.putAll(dynamicMapsOf(apiInjector));
+
+        List<Injector> allPluginInjectors =
+            listOfInjectors(
+                newPlugin.getSysInjector(),
+                newPlugin.getSshInjector(),
+                newPlugin.getHttpInjector());
+        allPluginInjectors.forEach(i -> reattachItem(old, apiItems, i, newPlugin));
+        allPluginInjectors.forEach(i -> reattachSet(old, apiSets, i, newPlugin));
+        allPluginInjectors.forEach(i -> reattachMap(old, apiMaps, i, newPlugin));
+      }
     } finally {
       exit(oldContext);
     }
@@ -648,4 +708,9 @@
     }
     return false;
   }
+
+  @Nullable
+  public Injector getApiInjector() {
+    return apiInjector;
+  }
 }
diff --git a/java/com/google/gerrit/server/plugins/PluginLoader.java b/java/com/google/gerrit/server/plugins/PluginLoader.java
index 3263636..8260104 100644
--- a/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -62,6 +62,7 @@
 import java.util.TreeSet;
 import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.TimeUnit;
+import java.util.jar.JarFile;
 import org.eclipse.jgit.internal.storage.file.FileSnapshot;
 import org.eclipse.jgit.lib.Config;
 
@@ -193,7 +194,9 @@
       try {
         Plugin plugin = runPlugin(name, dst, active);
         if (active == null) {
-          logger.atInfo().log("Installed plugin %s", plugin.getName());
+          logger.atInfo().log(
+              "Installed plugin %s%s",
+              plugin.getName(), plugin.getApiModule().isPresent() ? " (w/ ApiModule)" : "");
         }
       } catch (PluginInstallException e) {
         Files.deleteIfExists(dst);
@@ -217,7 +220,7 @@
     toCleanup.add(plugin);
   }
 
-  public void disablePlugins(Set<String> names) {
+  public void disablePlugins(Set<String> names) throws PluginInstallException {
     if (!isRemoteAdminEnabled()) {
       logger.atWarning().log(
           "Remote plugin administration is disabled, ignoring disablePlugins(%s)", names);
@@ -231,6 +234,12 @@
           continue;
         }
 
+        if (active.getApiModule().isPresent()) {
+          throw new PluginInstallException(
+              String.format(
+                  "Plugin %s has registered an ApiModule therefore it cannot be disabled", name));
+        }
+
         if (mandatoryPlugins.contains(name)) {
           logger.atWarning().log("Mandatory plugin %s cannot be disabled", name);
           continue;
@@ -350,7 +359,6 @@
       disabled.clear();
       broken.clear();
       if (!toCleanup.isEmpty()) {
-        System.gc();
         processPendingCleanups();
       }
     }
@@ -378,8 +386,14 @@
         try {
           logger.atInfo().log("Reloading plugin %s", name);
           Plugin newPlugin = runPlugin(name, active.getSrcFile(), active);
-          logger.atInfo().log(
-              "Reloaded plugin %s, version %s", newPlugin.getName(), newPlugin.getVersion());
+
+          if (newPlugin != active) {
+            logger.atInfo().log(
+                "Reloaded plugin %s%s, version %s",
+                newPlugin.getName(),
+                newPlugin.getApiModule().isPresent() ? " (w/ ApiModule)" : "",
+                newPlugin.getVersion());
+          }
         } catch (PluginInstallException e) {
           logger.atWarning().withCause(e.getCause()).log("Cannot reload plugin %s", name);
           throw e;
@@ -398,7 +412,7 @@
       syncDisabledPlugins(pluginsFiles);
 
       Map<String, Path> activePlugins = filterDisabled(pluginsFiles);
-      for (Map.Entry<String, Path> entry : jarsFirstSortedPluginsSet(activePlugins)) {
+      for (Map.Entry<String, Path> entry : jarsApiFirstSortedPluginsSet(activePlugins)) {
         String name = entry.getKey();
         Path path = entry.getValue();
         String fileName = path.getFileName().toString();
@@ -428,9 +442,10 @@
           if (!loadedPlugin.isDisabled()) {
             loadedPlugins.add(name);
             logger.atInfo().log(
-                "%s plugin %s, version %s",
+                "%s plugin %s%s, version %s",
                 active == null ? "Loaded" : "Reloaded",
                 loadedPlugin.getName(),
+                loadedPlugin.getApiModule().isPresent() ? " (w/ ApiModule)" : "",
                 loadedPlugin.getVersion());
           }
         } catch (PluginInstallException e) {
@@ -455,7 +470,7 @@
     }
   }
 
-  private TreeSet<Map.Entry<String, Path>> jarsFirstSortedPluginsSet(
+  private TreeSet<Map.Entry<String, Path>> jarsApiFirstSortedPluginsSet(
       Map<String, Path> activePlugins) {
     TreeSet<Map.Entry<String, Path>> sortedPlugins =
         Sets.newTreeSet(
@@ -464,14 +479,34 @@
               public int compare(Map.Entry<String, Path> e1, Map.Entry<String, Path> e2) {
                 Path n1 = e1.getValue().getFileName();
                 Path n2 = e2.getValue().getFileName();
-                return ComparisonChain.start()
-                    .compareTrueFirst(isJar(n1), isJar(n2))
-                    .compare(n1, n2)
-                    .result();
+
+                try {
+                  boolean e1IsApi = isApi(e1.getValue());
+                  boolean e2IsApi = isApi(e2.getValue());
+                  return ComparisonChain.start()
+                      .compareTrueFirst(e1IsApi, e2IsApi)
+                      .compareTrueFirst(isJar(n1), isJar(n2))
+                      .compare(n1, n2)
+                      .result();
+                } catch (IOException ioe) {
+                  logger.atSevere().withCause(ioe).log("Unable to compare %s and %s", n1, n2);
+                  return 0;
+                }
               }
 
-              private boolean isJar(Path n1) {
-                return n1.toString().endsWith(".jar");
+              private boolean isJar(Path pluginPath) {
+                return pluginPath.toString().endsWith(".jar");
+              }
+
+              private boolean isApi(Path pluginPath) throws IOException {
+                return isJar(pluginPath) && hasApiModuleEntryInManifest(pluginPath);
+              }
+
+              private boolean hasApiModuleEntryInManifest(Path pluginPath) throws IOException {
+                try (JarFile jarFile = new JarFile(pluginPath.toFile())) {
+                  return !Strings.isNullOrEmpty(
+                      jarFile.getManifest().getMainAttributes().getValue(ServerPlugin.API_MODULE));
+                }
               }
             });
 
@@ -494,6 +529,13 @@
         return oldPlugin;
       }
 
+      if (oldPlugin != null && oldPlugin.getApiModule().isPresent()) {
+        throw new PluginInstallException(
+            String.format(
+                "Plugin %s has registered an ApiModule therefore its restart/reload is not allowed",
+                name));
+      }
+
       Plugin newPlugin = loadPlugin(name, plugin, snapshot);
       if (newPlugin.getCleanupHandle() != null) {
         cleanupHandles.put(newPlugin, newPlugin.getCleanupHandle());
@@ -545,7 +587,13 @@
       }
     }
     for (String name : unload) {
-      unloadPlugin(running.get(name));
+      Plugin runningPlugin = running.get(name);
+
+      if (runningPlugin.getApiModule().isPresent()) {
+        logger.atWarning().log("Cannot remove plugin %s as it has registered an ApiModule", name);
+      } else {
+        unloadPlugin(running.get(name));
+      }
     }
   }
 
diff --git a/java/com/google/gerrit/server/plugins/PluginRestApiModule.java b/java/com/google/gerrit/server/plugins/PluginRestApiModule.java
index 8dbea78..b47db0d 100644
--- a/java/com/google/gerrit/server/plugins/PluginRestApiModule.java
+++ b/java/com/google/gerrit/server/plugins/PluginRestApiModule.java
@@ -27,12 +27,13 @@
     requireBinding(Key.get(PluginUser.Factory.class));
     bind(PluginsCollection.class);
     DynamicMap.mapOf(binder(), PLUGIN_KIND);
+
     create(PLUGIN_KIND).to(InstallPlugin.Create.class);
     put(PLUGIN_KIND).to(InstallPlugin.Overwrite.class);
     delete(PLUGIN_KIND).to(DisablePlugin.class);
-    get(PLUGIN_KIND, "status").to(GetStatus.class);
     post(PLUGIN_KIND, "disable").to(DisablePlugin.class);
     post(PLUGIN_KIND, "enable").to(EnablePlugin.class);
     post(PLUGIN_KIND, "reload").to(ReloadPlugin.class);
+    get(PLUGIN_KIND, "status").to(GetStatus.class);
   }
 }
diff --git a/java/com/google/gerrit/server/plugins/PluginUtil.java b/java/com/google/gerrit/server/plugins/PluginUtil.java
index 4f00cd0..4abf864 100644
--- a/java/com/google/gerrit/server/plugins/PluginUtil.java
+++ b/java/com/google/gerrit/server/plugins/PluginUtil.java
@@ -81,7 +81,7 @@
     return 0 < ext ? name.substring(0, ext) : name;
   }
 
-  static ClassLoader parentFor(Plugin.ApiType type) throws InvalidPluginException {
+  static ClassLoader parentFor(Plugin.ApiType type) {
     switch (type) {
       case EXTENSION:
         return PluginName.class.getClassLoader();
@@ -90,7 +90,7 @@
       case JS:
         return JavaScriptPlugin.class.getClassLoader();
       default:
-        throw new InvalidPluginException("Unsupported ApiType " + type);
+        throw new IllegalArgumentException("Unsupported ApiType " + type);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/plugins/ServerPlugin.java b/java/com/google/gerrit/server/plugins/ServerPlugin.java
index b90885e..c7d62d1 100644
--- a/java/com/google/gerrit/server/plugins/ServerPlugin.java
+++ b/java/com/google/gerrit/server/plugins/ServerPlugin.java
@@ -31,12 +31,14 @@
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
 import java.util.jar.Attributes;
 import java.util.jar.Manifest;
 import org.eclipse.jgit.internal.storage.file.FileSnapshot;
 
 public class ServerPlugin extends Plugin {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  public static final String API_MODULE = "Gerrit-ApiModule";
 
   private final Manifest manifest;
   private final PluginContentScanner scanner;
@@ -49,12 +51,16 @@
   protected Class<? extends Module> batchModule;
   protected Class<? extends Module> sshModule;
   protected Class<? extends Module> httpModule;
+  private Class<? extends Module> apiModuleClass;
 
+  private Injector apiInjector;
   private Injector sysInjector;
   private Injector sshInjector;
   private Injector httpInjector;
   private LifecycleManager serverManager;
 
+  private Optional<Module> apiModule = Optional.empty();
+
   public ServerPlugin(
       String name,
       String pluginCanonicalWebUrl,
@@ -92,6 +98,7 @@
     String sshName = main.getValue("Gerrit-SshModule");
     String httpName = main.getValue("Gerrit-HttpModule");
     String batchName = main.getValue("Gerrit-BatchModule");
+    String apiName = main.getValue(API_MODULE);
 
     if (!Strings.isNullOrEmpty(sshName) && getApiType() != Plugin.ApiType.PLUGIN) {
       throw new InvalidPluginException(
@@ -104,6 +111,7 @@
       this.sysModule = load(sysName, classLoader);
       this.sshModule = load(sshName, classLoader);
       this.httpModule = load(httpName, classLoader);
+      this.apiModuleClass = load(apiName, classLoader);
     } catch (ClassNotFoundException e) {
       throw new InvalidPluginException("Unable to load plugin Guice Modules", e);
     }
@@ -163,6 +171,11 @@
   @Override
   protected boolean canReload() {
     Attributes main = manifest.getMainAttributes();
+    String apiModule = main.getValue("Gerrit-ApiModule");
+    if (apiModule != null) {
+      return false;
+    }
+
     String v = main.getValue("Gerrit-ReloadMode");
     if (Strings.isNullOrEmpty(v) || "reload".equalsIgnoreCase(v)) {
       return true;
@@ -186,11 +199,10 @@
   }
 
   private void startPlugin(PluginGuiceEnvironment env) throws Exception {
-    Injector root = newRootInjector(env);
     serverManager = new LifecycleManager();
-    serverManager.add(root);
 
     if (gerritRuntime == GerritRuntime.BATCH) {
+      Injector root = newRootInjector(env);
       if (batchModule != null) {
         sysInjector = root.createChildInjector(root.getInstance(batchModule));
         serverManager.add(sysInjector);
@@ -203,19 +215,27 @@
     }
 
     AutoRegisterModules auto = null;
-    if (sysModule == null && sshModule == null && httpModule == null) {
+    if (sysModule == null && sshModule == null && httpModule == null && apiModuleClass == null) {
       auto = new AutoRegisterModules(getName(), env, scanner, classLoader);
       auto.discover();
     }
 
+    Injector baseInjector;
+    if (apiModuleClass == null) {
+      baseInjector = newRootInjector(env);
+    } else {
+      baseInjector = newRootInjectorWithApiModule(env, apiModuleClass);
+    }
+    serverManager.add(baseInjector);
+
     if (sysModule != null) {
-      sysInjector = root.createChildInjector(root.getInstance(sysModule));
+      sysInjector = baseInjector.createChildInjector(baseInjector.getInstance(sysModule));
       serverManager.add(sysInjector);
     } else if (auto != null && auto.sysModule != null) {
-      sysInjector = root.createChildInjector(auto.sysModule);
+      sysInjector = baseInjector.createChildInjector(auto.sysModule);
       serverManager.add(sysInjector);
     } else {
-      sysInjector = root;
+      sysInjector = baseInjector;
     }
 
     if (env.hasSshModule()) {
@@ -254,12 +274,31 @@
   }
 
   private Injector newRootInjector(PluginGuiceEnvironment env) {
+    Optional<Injector> apiInjector = Optional.ofNullable(env.getApiInjector());
+
     List<Module> modules = Lists.newArrayListWithCapacity(2);
     if (getApiType() == ApiType.PLUGIN) {
-      modules.add(env.getSysModule());
+      if (!apiInjector.isPresent()) {
+        modules.add(env.getSysModule());
+      }
     }
     modules.add(new ServerPluginInfoModule(this, env.getServerMetrics()));
-    return Guice.createInjector(modules);
+    return apiInjector
+        .map(injector -> injector.createChildInjector(modules))
+        .orElseGet(() -> Guice.createInjector(modules));
+  }
+
+  private Injector newRootInjectorWithApiModule(
+      PluginGuiceEnvironment env, Class<? extends Module> apiModuleClass) {
+
+    Injector baseInjector =
+        Optional.ofNullable(env.getApiInjector())
+            .orElseGet(() -> Guice.createInjector(env.getSysModule()));
+    apiModule = Optional.of(baseInjector.getInstance(apiModuleClass));
+    apiInjector = baseInjector.createChildInjector(apiModule.get());
+
+    return apiInjector.createChildInjector(
+        new ServerPluginInfoModule(this, env.getServerMetrics()));
   }
 
   @Override
@@ -284,6 +323,16 @@
   }
 
   @Override
+  public Injector getApiInjector() {
+    return apiInjector;
+  }
+
+  @Override
+  public Optional<Module> getApiModule() {
+    return apiModule;
+  }
+
+  @Override
   @Nullable
   public Injector getSshInjector() {
     return sshInjector;
diff --git a/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java b/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
index e91f7b7..0f7e87e 100644
--- a/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
+++ b/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
@@ -29,6 +29,7 @@
 import java.nio.file.Files;
 import java.nio.file.Path;
 
+@SuppressWarnings("ProvidesMethodOutsideOfModule")
 class ServerPluginInfoModule extends AbstractModule {
   private final ServerPlugin plugin;
   private final Path dataDir;
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 6498d1b..b940ccc 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -124,7 +124,10 @@
         // the existing performance guarantees of not requiring reads for the repo for values
         // cached in-memory but also to persist the cache which leads to a much improved
         // cold-start behavior and in-memory miss latency.
-        cache(CACHE_NAME, Project.NameKey.class, CachedProjectConfig.class)
+        cache(
+                CACHE_NAME,
+                Project.NameKey.class,
+                new TypeLiteral<Optional<CachedProjectConfig>>() {})
             .loader(InMemoryLoader.class)
             .refreshAfterWrite(Duration.ofMinutes(15))
             .expireAfterWrite(Duration.ofHours(1));
@@ -165,7 +168,7 @@
   private final Config config;
   private final AllProjectsName allProjectsName;
   private final AllUsersName allUsersName;
-  private final LoadingCache<Project.NameKey, CachedProjectConfig> inMemoryProjectCache;
+  private final LoadingCache<Project.NameKey, Optional<CachedProjectConfig>> inMemoryProjectCache;
   private final LoadingCache<ListKey, ImmutableSortedSet<Project.NameKey>> list;
   private final Lock listLock;
   private final Provider<ProjectIndexer> indexer;
@@ -177,7 +180,8 @@
       @GerritServerConfig Config config,
       AllProjectsName allProjectsName,
       AllUsersName allUsersName,
-      @Named(CACHE_NAME) LoadingCache<Project.NameKey, CachedProjectConfig> inMemoryProjectCache,
+      @Named(CACHE_NAME)
+          LoadingCache<Project.NameKey, Optional<CachedProjectConfig>> inMemoryProjectCache,
       @Named(CACHE_LIST) LoadingCache<ListKey, ImmutableSortedSet<Project.NameKey>> list,
       Provider<ProjectIndexer> indexer,
       MetricMaker metricMaker,
@@ -216,12 +220,8 @@
     }
 
     try {
-      return Optional.of(inMemoryProjectCache.get(projectName)).map(projectStateFactory::create);
+      return inMemoryProjectCache.get(projectName).map(projectStateFactory::create);
     } catch (ExecutionException e) {
-      if ((e.getCause() instanceof RepositoryNotFoundException)) {
-        logger.atFine().log("Cannot find project %s", projectName.get());
-        return Optional.empty();
-      }
       throw new StorageException(
           String.format("project state of project %s not available", projectName.get()), e);
     }
@@ -279,7 +279,7 @@
     } finally {
       listLock.unlock();
     }
-    indexer.get().index(newProjectName);
+    evictAndReindex(newProjectName);
   }
 
   @Override
@@ -308,7 +308,8 @@
                   all().stream()
                       .map(n -> inMemoryProjectCache.getIfPresent(n))
                       .filter(Objects::nonNull)
-                      .flatMap(p -> p.getAllGroupUUIDs().stream())
+                      .filter(Optional::isPresent)
+                      .flatMap(p -> p.get().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))
@@ -353,7 +354,7 @@
   }
 
   @Singleton
-  static class InMemoryLoader extends CacheLoader<Project.NameKey, CachedProjectConfig> {
+  static class InMemoryLoader extends CacheLoader<Project.NameKey, Optional<CachedProjectConfig>> {
     private final LoadingCache<Cache.ProjectCacheKeyProto, CachedProjectConfig> persistedCache;
     private final GitRepositoryManager repoManager;
     private final ListeningExecutorService cacheRefreshExecutor;
@@ -391,38 +392,54 @@
     }
 
     @Override
-    public CachedProjectConfig load(Project.NameKey key) throws IOException, ExecutionException {
-      try (TraceTimer ignored =
-              TraceContext.newTimer(
-                  "Loading project from serialized cache",
-                  Metadata.builder().projectName(key.get()).build());
-          Repository git = repoManager.openRepository(key)) {
-        Cache.ProjectCacheKeyProto.Builder keyProto =
-            Cache.ProjectCacheKeyProto.newBuilder().setProject(key.get());
-        Ref configRef = git.exactRef(RefNames.REFS_CONFIG);
-        if (key.get().equals(allProjectsName.get())) {
-          Optional<StoredConfig> allProjectsConfig = allProjectsConfigProvider.get(allProjectsName);
-          byte[] fileHash = allProjectsFileProjectConfigHash(allProjectsConfig);
-          keyProto.setGlobalConfigRevision(ByteString.copyFrom(fileHash));
+    public Optional<CachedProjectConfig> load(Project.NameKey key)
+        throws IOException, ExecutionException {
+      try {
+        try (TraceTimer ignored =
+                TraceContext.newTimer(
+                    "Loading project from serialized cache",
+                    Metadata.builder().projectName(key.get()).build());
+            Repository git = repoManager.openRepository(key)) {
+          Cache.ProjectCacheKeyProto.Builder keyProto =
+              Cache.ProjectCacheKeyProto.newBuilder().setProject(key.get());
+          Ref configRef = git.exactRef(RefNames.REFS_CONFIG);
+          if (key.get().equals(allProjectsName.get())) {
+            Optional<StoredConfig> allProjectsConfig =
+                allProjectsConfigProvider.get(allProjectsName);
+            byte[] fileHash = allProjectsFileProjectConfigHash(allProjectsConfig);
+            keyProto.setGlobalConfigRevision(ByteString.copyFrom(fileHash));
+          }
+          if (configRef != null) {
+            keyProto.setRevision(ObjectIdConverter.create().toByteString(configRef.getObjectId()));
+          }
+          return Optional.of(persistedCache.get(keyProto.build()));
         }
-        if (configRef != null) {
-          keyProto.setRevision(ObjectIdConverter.create().toByteString(configRef.getObjectId()));
-        }
-        return persistedCache.get(keyProto.build());
+      } catch (RepositoryNotFoundException e) {
+        return Optional.empty();
       }
     }
 
     @Override
-    public ListenableFuture<CachedProjectConfig> reload(
-        Project.NameKey key, CachedProjectConfig oldState) throws Exception {
+    public ListenableFuture<Optional<CachedProjectConfig>> reload(
+        Project.NameKey key, Optional<CachedProjectConfig> oldState) throws Exception {
       try (TraceTimer ignored =
           TraceContext.newTimer(
               "Reload project", Metadata.builder().projectName(key.get()).build())) {
         try (Repository git = repoManager.openRepository(key)) {
           Ref configRef = git.exactRef(RefNames.REFS_CONFIG);
-          if (configRef != null && configRef.getObjectId().equals(oldState.getRevision().get())) {
-            refreshCounter.increment(CACHE_NAME, false);
-            return Futures.immediateFuture(oldState);
+          if (oldState.isPresent()) {
+            if (configRef != null
+                && configRef.getObjectId().equals(oldState.get().getRevision().get())) {
+              // Old and new state are equivalent
+              refreshCounter.increment(CACHE_NAME, false);
+              return Futures.immediateFuture(oldState);
+            }
+          } else {
+            if (configRef == null) {
+              // Repository is still missing
+              refreshCounter.increment(CACHE_NAME, false);
+              return Futures.immediateFuture(Optional.empty());
+            }
           }
         }
 
diff --git a/java/com/google/gerrit/server/project/ProjectCreator.java b/java/com/google/gerrit/server/project/ProjectCreator.java
index 485d926..8bd18ff 100644
--- a/java/com/google/gerrit/server/project/ProjectCreator.java
+++ b/java/com/google/gerrit/server/project/ProjectCreator.java
@@ -116,6 +116,8 @@
           throw new RepositoryExistsException(nameKey, "Repository status: " + status);
         }
         try (Repository repo = repoManager.createRepository(nameKey)) {
+          projectCache.evict(nameKey);
+
           RefUpdate u = repo.updateRef(Constants.HEAD);
           u.disableRefLog();
           u.link(head);
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index a3f8009..cb286d6 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -423,7 +423,7 @@
 
     List<LabelType> r = Lists.newArrayListWithCapacity(all.size());
     for (LabelType l : all) {
-      List<String> refs = l.getRefPatterns();
+      ImmutableList<String> refs = l.getRefPatterns();
       if (refs == null) {
         r.add(l);
       } else {
@@ -502,7 +502,7 @@
   }
 
   public Collection<SubscribeSection> getSubscribeSections(BranchNameKey branch) {
-    Collection<SubscribeSection> ret = new ArrayList<>();
+    List<SubscribeSection> ret = new ArrayList<>();
     for (ProjectState s : tree()) {
       ret.addAll(s.getConfig().getSubscribeSections(branch));
     }
diff --git a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
index 9463b39..4946bea 100644
--- a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
@@ -35,7 +35,6 @@
 import com.google.gerrit.extensions.api.projects.CheckProjectResultInfo.AutoCloseableChangesCheckResult;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -44,7 +43,6 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.ChangeJson;
-import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -74,7 +72,7 @@
   private final RetryHelper retryHelper;
   private final ChangeJson.Factory changeJsonFactory;
   private final IndexConfig indexConfig;
-  private final DynamicItem<UrlFormatter> urlFormatter;
+  private final ChangeUtil changeUtil;
 
   @Inject
   ProjectsConsistencyChecker(
@@ -82,12 +80,12 @@
       RetryHelper retryHelper,
       ChangeJson.Factory changeJsonFactory,
       IndexConfig indexConfig,
-      DynamicItem<UrlFormatter> urlFormatter) {
+      ChangeUtil changeUtil) {
     this.repoManager = repoManager;
     this.retryHelper = retryHelper;
     this.changeJsonFactory = changeJsonFactory;
     this.indexConfig = indexConfig;
-    this.urlFormatter = urlFormatter;
+    this.changeUtil = changeUtil;
   }
 
   public CheckProjectResultInfo check(Project.NameKey projectName, CheckProjectInput input)
@@ -174,7 +172,7 @@
         mergedSha1s.add(commitId);
 
         // Consider all Change-Id lines since this is what ReceiveCommits#autoCloseChanges does.
-        List<String> changeIds = ChangeUtil.getChangeIdsFromFooter(commit, urlFormatter.get());
+        List<String> changeIds = changeUtil.getChangeIdsFromFooter(commit);
 
         // Number of predicates that we need to add for this commit, 1 per Change-Id plus one for
         // the commit.
diff --git a/java/com/google/gerrit/server/project/RefFilter.java b/java/com/google/gerrit/server/project/RefFilter.java
index cdabcbe..3f83f83 100644
--- a/java/com/google/gerrit/server/project/RefFilter.java
+++ b/java/com/google/gerrit/server/project/RefFilter.java
@@ -18,23 +18,25 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.extensions.api.projects.RefInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 import java.util.List;
 import java.util.Locale;
+import java.util.function.Function;
 import java.util.stream.Stream;
 
-public class RefFilter<T extends RefInfo> {
+public class RefFilter<T> {
   private final String prefix;
+  private final Function<T, String> refNameExtractor;
   private String matchSubstring;
   private String matchRegex;
   private int start;
   private int limit;
 
-  public RefFilter(String prefix) {
+  public RefFilter(String prefix, Function<T, String> refNameExtractor) {
     this.prefix = prefix;
+    this.refNameExtractor = refNameExtractor;
   }
 
   public RefFilter<T> subString(String subString) {
@@ -78,9 +80,8 @@
     return results.collect(toImmutableList());
   }
 
-  private static <T extends RefInfo> boolean matchesSubstring(
-      String prefix, String lowercaseSubstring, T refInfo) {
-    String ref = refInfo.ref;
+  private boolean matchesSubstring(String prefix, String lowercaseSubstring, T refInfo) {
+    String ref = refNameExtractor.apply(refInfo);
     if (ref.startsWith(prefix)) {
       ref = ref.substring(prefix.length());
     }
@@ -102,9 +103,8 @@
     }
   }
 
-  private static <T extends RefInfo> boolean matchesRegex(
-      String prefix, RunAutomaton a, T refInfo) {
-    String ref = refInfo.ref;
+  private boolean matchesRegex(String prefix, RunAutomaton a, T refInfo) {
+    String ref = refNameExtractor.apply(refInfo);
     if (ref.startsWith(prefix)) {
       ref = ref.substring(prefix.length());
     }
diff --git a/java/com/google/gerrit/server/project/RefPattern.java b/java/com/google/gerrit/server/project/RefPattern.java
index 5bac950..06cee7f 100644
--- a/java/com/google/gerrit/server/project/RefPattern.java
+++ b/java/com/google/gerrit/server/project/RefPattern.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.exceptions.InvalidNameException;
 import dk.brics.automaton.RegExp;
-import java.util.Map;
 import java.util.concurrent.ExecutionException;
 import java.util.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
@@ -82,7 +81,7 @@
     }
     ParameterizedString template = new ParameterizedString(refPattern);
     String replacement = "_PLACEHOLDER_";
-    Map<String, String> params =
+    ImmutableMap<String, String> params =
         ImmutableMap.of(
             RefPattern.USERID_SHARDED, replacement,
             RefPattern.USERNAME, replacement);
diff --git a/java/com/google/gerrit/server/project/RefPatternMatcher.java b/java/com/google/gerrit/server/project/RefPatternMatcher.java
index be840b5..798838e 100644
--- a/java/com/google/gerrit/server/project/RefPatternMatcher.java
+++ b/java/com/google/gerrit/server/project/RefPatternMatcher.java
@@ -151,7 +151,7 @@
     }
 
     private ImmutableSet<String> getUsernames(CurrentUser user) {
-      Stream<String> usernames = Streams.stream(user.getUserName());
+      Stream<String> usernames = user.getUserName().stream();
       if (user.isIdentifiedUser()) {
         usernames = Streams.concat(usernames, user.asIdentifiedUser().getEmailAddresses().stream());
       }
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
index b3278c9..7c2f293 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
@@ -17,7 +17,6 @@
 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;
@@ -34,10 +33,6 @@
   /** Evaluate a single {@link SubmitRequirement} using change data. */
   SubmitRequirementResult evaluateRequirement(SubmitRequirement sr, ChangeData cd);
 
-  /** Evaluate a {@link SubmitRequirementExpression} using change data. */
-  SubmitRequirementExpressionResult evaluateExpression(
-      SubmitRequirementExpression expression, ChangeData changeData);
-
   /**
    * Validate a {@link SubmitRequirementExpression}. Callers who wish to validate submit
    * requirements upon creation or update should use this method.
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
index 0991f20..7777678 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.ImmutableMap.toImmutableMap;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.SubmitRequirement;
@@ -49,6 +50,10 @@
   private final Provider<SubmitRequirementChangeQueryBuilder> queryBuilder;
   private final ProjectCache projectCache;
   private final PluginSetContext<SubmitRequirement> globalSubmitRequirements;
+
+  // 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).
   private final OneOffRequestContext requestContext;
 
   public static Module module() {
@@ -77,78 +82,28 @@
   @Override
   public void validateExpression(SubmitRequirementExpression expression)
       throws QueryParseException {
-    queryBuilder.get().parse(expression.expressionString());
+    try (ManualRequestContext ignored = requestContext.open()) {
+      queryBuilder.get().parse(expression.expressionString());
+    }
   }
 
   @Override
   public ImmutableMap<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(
       ChangeData cd) {
-    return getRequirements(cd);
+    try (ManualRequestContext ignored = requestContext.open()) {
+      return getRequirements(cd);
+    }
   }
 
   @Override
   public SubmitRequirementResult evaluateRequirement(SubmitRequirement sr, ChangeData 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> 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();
-      }
-
-      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(submittabilityResult)
-          .applicabilityExpressionResult(applicabilityResult)
-          .overrideExpressionResult(overrideResult)
-          .build();
+      return evaluateRequirementInternal(sr, cd);
     }
   }
 
-  @Override
+  /** Evaluate a {@link SubmitRequirementExpression} using change data. */
+  @VisibleForTesting
   public SubmitRequirementExpressionResult evaluateExpression(
       SubmitRequirementExpression expression, ChangeData changeData) {
     try {
@@ -162,6 +117,59 @@
     }
   }
 
+  private SubmitRequirementResult evaluateRequirementInternal(SubmitRequirement sr, ChangeData cd) {
+    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();
+    }
+
+    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(submittabilityResult)
+        .applicabilityExpressionResult(applicabilityResult)
+        .overrideExpressionResult(overrideResult)
+        .build();
+  }
+
   /**
    * Evaluate and return all {@link SubmitRequirement}s.
    *
@@ -194,7 +202,7 @@
     ImmutableMap.Builder<SubmitRequirement, SubmitRequirementResult> results =
         ImmutableMap.builder();
     for (SubmitRequirement requirement : requirements.values()) {
-      results.put(requirement, evaluateRequirement(requirement, cd));
+      results.put(requirement, evaluateRequirementInternal(requirement, cd));
     }
     return results.build();
   }
diff --git a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index c5928f6..866ce14 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -33,7 +33,7 @@
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.DefaultSubmitRule;
-import com.google.gerrit.server.rules.PrologRule;
+import com.google.gerrit.server.rules.PrologSubmitRuleUtil;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -76,7 +76,7 @@
   }
 
   private final ProjectCache projectCache;
-  private final PrologRule prologRule;
+  private final PrologSubmitRuleUtil prologSubmitRuleUtil;
   private final PluginSetContext<SubmitRule> submitRules;
   private final Metrics metrics;
   private final SubmitRuleOptions opts;
@@ -85,12 +85,12 @@
   @Inject
   private SubmitRuleEvaluator(
       ProjectCache projectCache,
-      PrologRule prologRule,
+      PrologSubmitRuleUtil prologSubmitRuleUtil,
       PluginSetContext<SubmitRule> submitRules,
       Metrics metrics,
       @Assisted SubmitRuleOptions options) {
     this.projectCache = projectCache;
-    this.prologRule = prologRule;
+    this.prologSubmitRuleUtil = prologSubmitRuleUtil;
     this.submitRules = submitRules;
     this.metrics = metrics;
 
@@ -121,6 +121,15 @@
         throw new StorageException("Change not found");
       }
 
+      ProjectState projectState =
+          projectCache
+              .get(cd.project())
+              .orElseThrow(
+                  () ->
+                      new IllegalStateException(
+                          "Unable to find project while evaluating submit rule",
+                          new NoSuchProjectException(cd.project())));
+
       if (cd.change().isClosed()
           && (!opts.recomputeOnClosedChanges() || OnlineReindexMode.isActive())) {
         return cd.notes().getSubmitRecords().stream()
@@ -136,15 +145,6 @@
             .collect(toImmutableList());
       }
 
-      ProjectState projectState =
-          projectCache
-              .get(cd.project())
-              .orElseThrow(
-                  () ->
-                      new IllegalStateException(
-                          "Unable to find project while evaluating submit rule",
-                          new NoSuchProjectException(cd.project())));
-
       // We evaluate all the plugin-defined evaluators,
       // and then we collect the results in one list.
       return Streams.stream(submitRules)
@@ -190,7 +190,7 @@
         throw new IllegalStateException("Unable to find project while evaluating submit rule", e);
       }
 
-      return prologRule.getSubmitType(cd);
+      return prologSubmitRuleUtil.getSubmitType(cd);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java b/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
index d812eef..9df01f4 100644
--- a/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
@@ -25,13 +25,13 @@
 import com.google.gerrit.index.query.QueryProcessor;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.account.AccountState;
 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;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/query/change/AndChangeSource.java b/java/com/google/gerrit/server/query/change/AndChangeSource.java
index e4f768e..6c03425 100644
--- a/java/com/google/gerrit/server/query/change/AndChangeSource.java
+++ b/java/com/google/gerrit/server/query/change/AndChangeSource.java
@@ -33,7 +33,8 @@
 
   @Override
   public boolean hasChange() {
-    return source instanceof ChangeDataSource && ((ChangeDataSource) source).hasChange();
+    return filteredSource instanceof ChangeDataSource
+        && ((ChangeDataSource) filteredSource).hasChange();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index d2cd01f..6a14042 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -28,7 +28,6 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
-import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
@@ -38,6 +37,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.BranchNameKey;
@@ -55,6 +55,7 @@
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.entities.SubmitRequirementResult.Status;
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -63,18 +64,19 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.StarRef;
+import com.google.gerrit.server.StarredChangesReader;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.CommentThread;
 import com.google.gerrit.server.change.CommentThreads;
 import com.google.gerrit.server.change.MergeabilityCache;
 import com.google.gerrit.server.change.PureRevert;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SkipCurrentRulesEvaluationOnClosedChanges;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -102,7 +104,7 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.HashMap;
+import java.util.HashSet;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
@@ -112,6 +114,7 @@
 import java.util.function.Function;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
@@ -339,6 +342,8 @@
             null,
             null,
             null,
+            null,
+            null,
             virtualIdAlgo,
             false,
             project,
@@ -358,12 +363,14 @@
   }
 
   // Injected fields.
-  private @Nullable final StarredChangesUtil starredChangesUtil;
+  private @Nullable final StarredChangesReader starredChangesReader;
   private final AllUsersName allUsersName;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
   private final ChangeNotes.Factory notesFactory;
   private final CommentsUtil commentsUtil;
+
+  private final DraftCommentsReader draftCommentsReader;
   private final GitRepositoryManager repoManager;
   private final MergeUtilFactory mergeUtilFactory;
   private final MergeabilityCache mergeabilityCache;
@@ -372,6 +379,8 @@
   private final ProjectCache projectCache;
   private final TrackingFooters trackingFooters;
   private final PureRevert pureRevert;
+  private final boolean propagateSubmitRequirementErrors;
+
   private final SubmitRequirementsEvaluator submitRequirementsEvaluator;
   private final SubmitRequirementsUtil submitRequirementsUtil;
   private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
@@ -401,8 +410,8 @@
   private List<PatchSetApproval> currentApprovals;
   private List<String> currentFiles;
   private Optional<DiffSummary> diffSummary;
-  private Collection<HumanComment> publishedComments;
-  private Collection<RobotComment> robotComments;
+  private List<HumanComment> publishedComments;
+  private List<RobotComment> robotComments;
   private CurrentUser visibleTo;
   private List<ChangeMessage> messages;
   private Optional<ChangedLines> changedLines;
@@ -412,6 +421,7 @@
   private Boolean mergeable;
   private ObjectId metaRevision;
   private Set<String> hashtags;
+  private ImmutableMap<String, String> customKeyedValues;
 
   /**
    * Map from {@link com.google.gerrit.entities.Account.Id} to the tip of the edit ref for this
@@ -425,11 +435,11 @@
    * Map from {@link com.google.gerrit.entities.Account.Id} to the tip of the draft comments ref for
    * this change and the user.
    */
-  private Map<Account.Id, ObjectId> draftsByUser;
+  private Set<Account.Id> usersWithDrafts;
 
-  private ImmutableListMultimap<Account.Id, String> stars;
-  private StarsOf starsOf;
-  private ImmutableMap<Account.Id, StarRef> starRefs;
+  private ImmutableList<Account.Id> stars;
+  private Account.Id starredBy;
+  private ImmutableMap<Account.Id, Ref> starRefs;
   private ReviewerSet reviewers;
   private ReviewerByEmailSet reviewersByEmail;
   private ReviewerSet pendingReviewers;
@@ -447,16 +457,18 @@
   private ImmutableList<byte[]> refStatePatterns;
   private String changeServerId;
   private ChangeNumberVirtualIdAlgorithm virtualIdFunc;
+  private Boolean failedParsingFromIndex = false;
   private Change.Id virtualId;
 
   @Inject
   private ChangeData(
-      @Nullable StarredChangesUtil starredChangesUtil,
+      @Nullable StarredChangesReader starredChangesReader,
       ApprovalsUtil approvalsUtil,
       AllUsersName allUsersName,
       ChangeMessagesUtil cmUtil,
       ChangeNotes.Factory notesFactory,
       CommentsUtil commentsUtil,
+      DraftCommentsReader draftCommentsReader,
       GitRepositoryManager repoManager,
       MergeUtilFactory mergeUtilFactory,
       MergeabilityCache mergeabilityCache,
@@ -465,6 +477,7 @@
       ProjectCache projectCache,
       TrackingFooters trackingFooters,
       PureRevert pureRevert,
+      @GerritServerConfig Config serverConfig,
       SubmitRequirementsEvaluator submitRequirementsEvaluator,
       SubmitRequirementsUtil submitRequirementsUtil,
       SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory,
@@ -480,15 +493,20 @@
     this.cmUtil = cmUtil;
     this.notesFactory = notesFactory;
     this.commentsUtil = commentsUtil;
+    this.draftCommentsReader = draftCommentsReader;
     this.repoManager = repoManager;
     this.mergeUtilFactory = mergeUtilFactory;
     this.mergeabilityCache = mergeabilityCache;
     this.patchListCache = patchListCache;
     this.psUtil = psUtil;
     this.projectCache = projectCache;
-    this.starredChangesUtil = starredChangesUtil;
+    this.starredChangesReader = starredChangesReader;
     this.trackingFooters = trackingFooters;
     this.pureRevert = pureRevert;
+    this.propagateSubmitRequirementErrors =
+        serverConfig != null
+            ? serverConfig.getBoolean("change", "propagateSubmitRequirementErrors", false)
+            : false;
     this.submitRequirementsEvaluator = submitRequirementsEvaluator;
     this.submitRequirementsUtil = submitRequirementsUtil;
     this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
@@ -531,6 +549,15 @@
     return allUsersName;
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public void setFailedParsingFromIndex(Boolean val) {
+    this.failedParsingFromIndex = val;
+  }
+
+  public boolean hasFailedParsingFromIndex() {
+    return failedParsingFromIndex;
+  }
+
   @VisibleForTesting
   public void setCurrentFilePaths(List<String> filePaths) {
     PatchSet ps = currentPatchSet();
@@ -1164,6 +1191,14 @@
       if (c == null || !c.isClosed()) {
         // Open changes: Evaluate submit requirements online.
         submitRequirements = submitRequirementsEvaluator.evaluateAllRequirements(this);
+        if (propagateSubmitRequirementErrors) {
+          for (SubmitRequirementResult result : submitRequirements.values()) {
+            if (result.status() == Status.ERROR) {
+              throw new IllegalStateException(result.errorMessage().orElse("(no message)"));
+            }
+          }
+        }
+
         return submitRequirements;
       }
       // Closed changes: Load submit requirement results from NoteDb.
@@ -1316,7 +1351,17 @@
   }
 
   public Set<Account.Id> draftsByUser() {
-    return draftRefs().keySet();
+    if (usersWithDrafts == null) {
+      if (!lazyload()) {
+        return Collections.emptySet();
+      }
+      Change c = change();
+      if (c == null) {
+        return Collections.emptySet();
+      }
+      usersWithDrafts = draftCommentsReader.getUsersWithDrafts(notes());
+    }
+    return usersWithDrafts;
   }
 
   public boolean isReviewedBy(Account.Id accountId) {
@@ -1369,51 +1414,63 @@
     this.hashtags = hashtags;
   }
 
-  public ImmutableListMultimap<Account.Id, String> stars() {
+  public Map<String, String> customKeyedValues() {
+    if (customKeyedValues == null) {
+      if (!lazyload()) {
+        return Collections.emptyMap();
+      }
+      customKeyedValues = notes().getCustomKeyedValues();
+    }
+    return customKeyedValues;
+  }
+
+  public void setCustomKeyedValues(Map<String, String> customKeyedValues) {
+    this.customKeyedValues = ImmutableMap.copyOf(customKeyedValues);
+  }
+
+  public ImmutableList<Account.Id> stars() {
     if (stars == null) {
       if (!lazyload()) {
-        return ImmutableListMultimap.of();
+        return ImmutableList.of();
       }
-      ImmutableListMultimap.Builder<Account.Id, String> b = ImmutableListMultimap.builder();
-      for (Map.Entry<Account.Id, StarRef> e : starRefs().entrySet()) {
-        b.putAll(e.getKey(), e.getValue().labels());
-      }
-      return b.build();
+      return starRefs().keySet().asList();
     }
     return stars;
   }
 
-  public void setStars(ListMultimap<Account.Id, String> stars) {
-    this.stars = ImmutableListMultimap.copyOf(stars);
+  public void setStars(List<Account.Id> accountIds) {
+    this.stars = ImmutableList.copyOf(accountIds);
   }
 
-  private ImmutableMap<Account.Id, StarRef> starRefs() {
+  private ImmutableMap<Account.Id, Ref> starRefs() {
     if (starRefs == null) {
       if (!lazyload()) {
         return ImmutableMap.of();
       }
-      starRefs = requireNonNull(starredChangesUtil).byChange(virtualId());
+      starRefs = requireNonNull(starredChangesReader).byChange(virtualId());
     }
     return starRefs;
   }
 
-  public Set<String> stars(Account.Id accountId) {
-    if (starsOf != null) {
-      if (!starsOf.accountId().equals(accountId)) {
-        starsOf = null;
+  public boolean isStarred(Account.Id accountId) {
+    if (starredBy != null) {
+      if (!starredBy.equals(accountId)) {
+        starredBy = null;
       }
     }
-    if (starsOf == null) {
-      if (stars != null) {
-        starsOf = StarsOf.create(accountId, stars.get(accountId));
+    if (starredBy == null) {
+      if (stars != null && stars.contains(accountId)) {
+        starredBy = accountId;
       } else {
         if (!lazyload()) {
-          return ImmutableSet.of();
+          return false;
         }
-        starsOf = StarsOf.create(accountId, starredChangesUtil.getLabels(accountId, virtualId()));
+        if (starredChangesReader.isStarred(accountId, legacyId)) {
+          starredBy = accountId;
+        }
       }
     }
-    return starsOf.stars();
+    return starredBy != null;
   }
 
   /**
@@ -1484,16 +1541,16 @@
 
   public void setRefStates(ImmutableSetMultimap<Project.NameKey, RefState> refStates) {
     this.refStates = refStates;
-    if (draftsByUser == null) {
-      // Recover draft refs as well. Draft comments are represented as refs in the repository.
+    if (usersWithDrafts == null) {
+      // Recover draft state as well.
       // ChangeData exposes #draftsByUser which just provides a Set of Account.Ids of users who
       // have drafts comments on this change. Recovering this list from RefStates makes it
       // available even on ChangeData instances retrieved from the index.
-      draftsByUser = new HashMap<>();
+      usersWithDrafts = new HashSet<>();
       if (refStates.containsKey(allUsersName)) {
         refStates.get(allUsersName).stream()
             .filter(r -> RefNames.isRefsDraftsComments(r.ref()))
-            .forEach(r -> draftsByUser.put(Account.Id.fromRef(r.ref()), r.id()));
+            .forEach(r -> usersWithDrafts.add(Account.Id.fromRef(r.ref())));
       }
     }
   }
@@ -1524,43 +1581,4 @@
 
     public abstract Instant ts();
   }
-
-  @AutoValue
-  abstract static class StarsOf {
-    private static StarsOf create(Account.Id accountId, Iterable<String> stars) {
-      return new AutoValue_ChangeData_StarsOf(accountId, ImmutableSortedSet.copyOf(stars));
-    }
-
-    public abstract Account.Id accountId();
-
-    public abstract ImmutableSortedSet<String> stars();
-  }
-
-  private Map<Account.Id, ObjectId> draftRefs() {
-    if (draftsByUser == null) {
-      if (!lazyload()) {
-        return Collections.emptyMap();
-      }
-      Change c = change();
-      if (c == null) {
-        return Collections.emptyMap();
-      }
-
-      draftsByUser = new HashMap<>();
-      for (Ref ref : commentsUtil.getDraftRefs(notes().getChangeId())) {
-        Account.Id account = Account.Id.fromRefSuffix(ref.getName());
-        if (account != null
-            // Double-check that any drafts exist for this user after
-            // filtering out zombies. If some but not all drafts in the ref
-            // were zombies, the returned Ref still includes those zombies;
-            // this is suboptimal, but is ok for the purposes of
-            // draftsByUser(), and easier than trying to rebuild the change at
-            // this point.
-            && !notes().getDraftComments(account, virtualId(), ref).isEmpty()) {
-          draftsByUser.put(account, ref.getObjectId());
-        }
-      }
-    }
-    return draftsByUser;
-  }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index 528d0ce..d5cc9e6 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -23,10 +23,12 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.DraftCommentsReader;
+import com.google.gerrit.server.StarredChangesReader;
 import com.google.gerrit.server.change.HashtagsUtil;
 import com.google.gerrit.server.index.change.ChangeField;
+import com.google.inject.ImplementedBy;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
@@ -61,21 +63,35 @@
     return new ChangeIndexPredicate(ChangeField.COMMENTBY_SPEC, id.toString());
   }
 
+  @ImplementedBy(IndexEditByPredicateProvider.class)
+  public interface EditByPredicateProvider {
+
+    /**
+     * Returns a predicate that matches changes where the provided {@link
+     * com.google.gerrit.entities.Account.Id} has a pending change edit.
+     */
+    Predicate<ChangeData> editBy(Account.Id id) throws QueryParseException;
+  }
+
   /**
-   * Returns a predicate that matches changes where the provided {@link
-   * com.google.gerrit.entities.Account.Id} has a pending change edit.
+   * A default implementation of {@link EditByPredicateProvider}, based on th {@link
+   * ChangeField#EDITBY_SPEC} index field.
    */
-  public static Predicate<ChangeData> editBy(Account.Id id) {
-    return new ChangeIndexPredicate(ChangeField.EDITBY_SPEC, id.toString());
+  public static class IndexEditByPredicateProvider implements EditByPredicateProvider {
+    @Override
+    public Predicate<ChangeData> editBy(Account.Id id) {
+      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(CommentsUtil commentsUtil, Account.Id id) {
+  public static Predicate<ChangeData> draftBy(
+      DraftCommentsReader draftCommentsReader, Account.Id id) {
     Set<Predicate<ChangeData>> changeIdPredicates =
-        commentsUtil.getChangesWithDrafts(id).stream()
+        draftCommentsReader.getChangesWithDrafts(id).stream()
             .map(ChangePredicates::idStr)
             .collect(toImmutableSet());
     return changeIdPredicates.isEmpty()
@@ -87,9 +103,10 @@
    * Returns a predicate that matches changes where the provided {@link
    * com.google.gerrit.entities.Account.Id} has starred changes with {@code label}.
    */
-  public static Predicate<ChangeData> starBy(StarredChangesUtil starredChangesUtil, Account.Id id) {
+  public static Predicate<ChangeData> starBy(
+      StarredChangesReader starredChangesReader, Account.Id id) {
     Set<Predicate<ChangeData>> starredChanges =
-        starredChangesUtil.byAccountId(id).stream()
+        starredChangesReader.byAccountId(id).stream()
             .map(ChangePredicates::idStr)
             .collect(toImmutableSet());
     return starredChanges.isEmpty() ? ChangeIndexPredicate.none() : Predicate.or(starredChanges);
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 816936b..da14d45 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -55,8 +56,9 @@
 import com.google.gerrit.index.query.QueryRequiresAuthException;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesReader;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
@@ -87,6 +89,7 @@
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ChildProjects;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.change.ChangePredicates.EditByPredicateProvider;
 import com.google.gerrit.server.query.change.PredicateArgs.ValOp;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.submit.SubmitDryRun;
@@ -165,6 +168,7 @@
   public static final String FIELD_COMMENTBY = "commentby";
   public static final String FIELD_COMMIT = "commit";
   public static final String FIELD_COMMITTER = "committer";
+  public static final String FIELD_CUSTOM_KEYED_VALUES = "custom_keyed_values";
   public static final String FIELD_DIRECTORY = "directory";
   public static final String FIELD_EXACTCOMMITTER = "exactcommitter";
   public static final String FIELD_EXTENSION = "extension";
@@ -255,6 +259,7 @@
     final ChangeIndex index;
     final ChangeIndexRewriter rewriter;
     final CommentsUtil commentsUtil;
+    final DraftCommentsReader draftCommentsReader;
     final ConflictsCache conflictsCache;
     final DynamicMap<ChangeHasOperandFactory> hasOperands;
     final DynamicMap<ChangeIsOperandFactory> isOperands;
@@ -267,7 +272,7 @@
     final ProjectCache projectCache;
     final Provider<InternalChangeQuery> queryProvider;
     final ChildProjects childProjects;
-    final StarredChangesUtil starredChangesUtil;
+    final StarredChangesReader starredChangesReader;
     final SubmitDryRun submitDryRun;
     final GroupMembers groupMembers;
     final ChangeIsVisibleToPredicate.Factory changeIsVisbleToPredicateFactory;
@@ -280,6 +285,8 @@
 
     private final Provider<CurrentUser> self;
 
+    private final EditByPredicateProvider editByPredicateProvider;
+
     @Inject
     @VisibleForTesting
     public Arguments(
@@ -293,6 +300,7 @@
         PermissionBackend permissionBackend,
         ChangeData.Factory changeDataFactory,
         CommentsUtil commentsUtil,
+        DraftCommentsReader draftCommentsReader,
         AccountResolver accountResolver,
         GroupBackend groupBackend,
         AllProjectsName allProjectsName,
@@ -305,7 +313,7 @@
         SubmitDryRun submitDryRun,
         ConflictsCache conflictsCache,
         IndexConfig indexConfig,
-        StarredChangesUtil starredChangesUtil,
+        StarredChangesReader starredChangesReader,
         AccountCache accountCache,
         GroupMembers groupMembers,
         OperatorAliasConfig operatorAliasConfig,
@@ -313,7 +321,8 @@
         ExperimentFeatures experimentFeatures,
         HasOperandAliasConfig hasOperandAliasConfig,
         ChangeIsVisibleToPredicate.Factory changeIsVisbleToPredicateFactory,
-        PluginSetContext<SubmitRule> submitRules) {
+        PluginSetContext<SubmitRule> submitRules,
+        EditByPredicateProvider editByPredicateProvider) {
       this(
           queryProvider,
           rewriter,
@@ -325,6 +334,7 @@
           permissionBackend,
           changeDataFactory,
           commentsUtil,
+          draftCommentsReader,
           accountResolver,
           groupBackend,
           allProjectsName,
@@ -337,7 +347,7 @@
           conflictsCache,
           indexes != null ? indexes.getSearchIndex() : null,
           indexConfig,
-          starredChangesUtil,
+          starredChangesReader,
           accountCache,
           groupMembers,
           operatorAliasConfig,
@@ -346,7 +356,8 @@
           experimentFeatures,
           hasOperandAliasConfig,
           changeIsVisbleToPredicateFactory,
-          submitRules);
+          submitRules,
+          editByPredicateProvider);
     }
 
     private Arguments(
@@ -360,6 +371,7 @@
         PermissionBackend permissionBackend,
         ChangeData.Factory changeDataFactory,
         CommentsUtil commentsUtil,
+        DraftCommentsReader draftCommentsReader,
         AccountResolver accountResolver,
         GroupBackend groupBackend,
         AllProjectsName allProjectsName,
@@ -372,7 +384,7 @@
         ConflictsCache conflictsCache,
         ChangeIndex index,
         IndexConfig indexConfig,
-        StarredChangesUtil starredChangesUtil,
+        StarredChangesReader starredChangesReader,
         AccountCache accountCache,
         GroupMembers groupMembers,
         OperatorAliasConfig operatorAliasConfig,
@@ -381,7 +393,8 @@
         ExperimentFeatures experimentFeatures,
         HasOperandAliasConfig hasOperandAliasConfig,
         ChangeIsVisibleToPredicate.Factory changeIsVisbleToPredicateFactory,
-        PluginSetContext<SubmitRule> submitRules) {
+        PluginSetContext<SubmitRule> submitRules,
+        EditByPredicateProvider editByPredicateProvider) {
       this.queryProvider = queryProvider;
       this.rewriter = rewriter;
       this.opFactories = opFactories;
@@ -390,6 +403,7 @@
       this.permissionBackend = permissionBackend;
       this.changeDataFactory = changeDataFactory;
       this.commentsUtil = commentsUtil;
+      this.draftCommentsReader = draftCommentsReader;
       this.accountResolver = accountResolver;
       this.groupBackend = groupBackend;
       this.allProjectsName = allProjectsName;
@@ -402,7 +416,7 @@
       this.conflictsCache = conflictsCache;
       this.index = index;
       this.indexConfig = indexConfig;
-      this.starredChangesUtil = starredChangesUtil;
+      this.starredChangesReader = starredChangesReader;
       this.accountCache = accountCache;
       this.hasOperands = hasOperands;
       this.isOperands = isOperands;
@@ -414,6 +428,7 @@
       this.experimentFeatures = experimentFeatures;
       this.hasOperandAliasConfig = hasOperandAliasConfig;
       this.submitRules = submitRules;
+      this.editByPredicateProvider = editByPredicateProvider;
     }
 
     public Arguments asUser(CurrentUser otherUser) {
@@ -428,6 +443,7 @@
           permissionBackend,
           changeDataFactory,
           commentsUtil,
+          draftCommentsReader,
           accountResolver,
           groupBackend,
           allProjectsName,
@@ -440,7 +456,7 @@
           conflictsCache,
           index,
           indexConfig,
-          starredChangesUtil,
+          starredChangesReader,
           accountCache,
           groupMembers,
           operatorAliasConfig,
@@ -449,7 +465,8 @@
           experimentFeatures,
           hasOperandAliasConfig,
           changeIsVisbleToPredicateFactory,
-          submitRules);
+          submitRules,
+          editByPredicateProvider);
     }
 
     Arguments asUser(Account.Id otherId) {
@@ -492,8 +509,8 @@
 
   protected final Arguments args;
   protected Map<String, String> hasOperandAliases = Collections.emptyMap();
-  private final Map<Account.Id, DestinationList> destinationListByAccount = new HashMap<>();
-  private final Map<Account.Id, QueryList> queryListByAccount = new HashMap<>();
+  private final Map<BranchNameKey, DestinationList> destinationListByBranch = new HashMap<>();
+  private final Map<BranchNameKey, QueryList> queryListByBranch = new HashMap<>();
 
   private static final Splitter RULE_SPLITTER = Splitter.on("=");
   private static final Splitter PLUGIN_SPLITTER = Splitter.on("_");
@@ -641,7 +658,7 @@
     }
 
     if ("edit".equalsIgnoreCase(value)) {
-      return ChangePredicates.editBy(self());
+      return this.args.editByPredicateProvider.editBy(self());
     }
 
     if ("attention".equalsIgnoreCase(value)) {
@@ -1152,6 +1169,21 @@
   }
 
   @Operator
+  public Predicate<ChangeData> d(String text) throws QueryParseException {
+    return message(text);
+  }
+
+  @Operator
+  public Predicate<ChangeData> description(String text) throws QueryParseException {
+    return message(text);
+  }
+
+  @Operator
+  public Predicate<ChangeData> m(String text) throws QueryParseException {
+    return message(text);
+  }
+
+  @Operator
   public Predicate<ChangeData> message(String text) throws QueryParseException {
     if (text.startsWith("^")) {
       checkFieldAvailable(
@@ -1176,11 +1208,11 @@
   }
 
   private Predicate<ChangeData> starredBySelf() throws QueryParseException {
-    return ChangePredicates.starBy(args.starredChangesUtil, self());
+    return ChangePredicates.starBy(args.starredChangesReader, self());
   }
 
   private Predicate<ChangeData> draftBySelf() throws QueryParseException {
-    return ChangePredicates.draftBy(args.commentsUtil, self());
+    return ChangePredicates.draftBy(args.draftCommentsReader, self());
   }
 
   @Operator
@@ -1416,11 +1448,16 @@
 
   @Operator
   public Predicate<ChangeData> query(String value) throws QueryParseException {
-    // [name=]<name>[,user=<user>] || [user=<user>,][name=]<name>
+    // [name=]NAME[,user=USER|,group=GROUP]
     PredicateArgs inputArgs = new PredicateArgs(value);
     String name = null;
     Account.Id account = null;
+    GroupDescription.Internal group = null;
 
+    if (inputArgs.keyValue.containsKey(ARG_ID_USER)
+        && inputArgs.keyValue.containsKey(ARG_ID_GROUP)) {
+      throw new QueryParseException("User and group arguments are mutually exclusive");
+    }
     // [name=]<name>
     if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
       name = inputArgs.keyValue.get(ARG_ID_NAME).value();
@@ -1444,7 +1481,23 @@
         account = self();
       }
 
-      String query = getQueryList(account).getQuery(name);
+      // [,group=<group>]
+      if (inputArgs.keyValue.containsKey(ARG_ID_GROUP)) {
+        AccountGroup.UUID groupId =
+            parseGroup(inputArgs.keyValue.get(ARG_ID_GROUP).value()).getUUID();
+        GroupDescription.Basic backendGroup = args.groupBackend.get(groupId);
+        if (!(backendGroup instanceof GroupDescription.Internal)) {
+          throw error(backendGroup.getName() + " is not an Internal group");
+        }
+        group = (GroupDescription.Internal) backendGroup;
+      }
+
+      BranchNameKey branch = BranchNameKey.create(args.allUsersName, RefNames.refsUsers(account));
+      if (group != null) {
+        branch = BranchNameKey.create(args.allUsersName, RefNames.refsGroups(group.getGroupUUID()));
+      }
+
+      String query = getQueryList(branch).getQuery(name);
       if (query != null) {
         return parse(query);
       }
@@ -1457,17 +1510,19 @@
     throw new QueryParseException("Unknown named query: " + name);
   }
 
-  protected QueryList getQueryList(Account.Id account) throws ConfigInvalidException, IOException {
-    QueryList ql = queryListByAccount.get(account);
+  protected QueryList getQueryList(BranchNameKey branch)
+      throws ConfigInvalidException, IOException {
+    QueryList ql = queryListByBranch.get(branch);
     if (ql == null) {
-      ql = loadQueryList(account);
-      queryListByAccount.put(account, ql);
+      ql = loadQueryList(branch);
+      queryListByBranch.put(branch, ql);
     }
     return ql;
   }
 
-  protected QueryList loadQueryList(Account.Id account) throws ConfigInvalidException, IOException {
-    VersionedAccountQueries q = VersionedAccountQueries.forUser(account);
+  protected QueryList loadQueryList(BranchNameKey branch)
+      throws ConfigInvalidException, IOException {
+    VersionedAccountQueries q = VersionedAccountQueries.forBranch(branch);
     try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
       q.load(args.allUsersName, git);
     }
@@ -1482,11 +1537,16 @@
 
   @Operator
   public Predicate<ChangeData> destination(String value) throws QueryParseException {
-    // [name=]<name>[,user=<user>] || [user=<user>,][name=]<name>
+    // [name=]<name>[,user=<user>|,group=<group>] || [group=<group>,|user=<user>,][name=]<name>
     PredicateArgs inputArgs = new PredicateArgs(value);
     String name = null;
     Account.Id account = null;
+    GroupDescription.Internal group = null;
 
+    if (inputArgs.keyValue.containsKey(ARG_ID_USER)
+        && inputArgs.keyValue.containsKey(ARG_ID_GROUP)) {
+      throw new QueryParseException("User and group arguments are mutually exclusive");
+    }
     // [name=]<name>
     if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
       name = inputArgs.keyValue.get(ARG_ID_NAME).value();
@@ -1510,7 +1570,23 @@
         account = self();
       }
 
-      Set<BranchNameKey> destinations = getDestinationList(account).getDestinations(name);
+      // [,group=<group>]
+      if (inputArgs.keyValue.containsKey(ARG_ID_GROUP)) {
+        AccountGroup.UUID groupId =
+            parseGroup(inputArgs.keyValue.get(ARG_ID_GROUP).value()).getUUID();
+        GroupDescription.Basic backendGroup = args.groupBackend.get(groupId);
+        if (!(backendGroup instanceof GroupDescription.Internal)) {
+          throw error(backendGroup.getName() + " is not an Internal group");
+        }
+        group = (GroupDescription.Internal) backendGroup;
+      }
+
+      BranchNameKey branch = BranchNameKey.create(args.allUsersName, RefNames.refsUsers(account));
+      if (group != null) {
+        branch = BranchNameKey.create(args.allUsersName, RefNames.refsGroups(group.getGroupUUID()));
+      }
+      Set<BranchNameKey> destinations = getDestinationList(branch).getDestinations(name);
+
       if (destinations != null && !destinations.isEmpty()) {
         return new BranchSetIndexPredicate(FIELD_DESTINATION + ":" + value, destinations);
       }
@@ -1523,19 +1599,19 @@
     throw new QueryParseException("Unknown named destination: " + name);
   }
 
-  protected DestinationList getDestinationList(Account.Id account)
+  protected DestinationList getDestinationList(BranchNameKey branch)
       throws ConfigInvalidException, RepositoryNotFoundException, IOException {
-    DestinationList dl = destinationListByAccount.get(account);
+    DestinationList dl = destinationListByBranch.get(branch);
     if (dl == null) {
-      dl = loadDestinationList(account);
-      destinationListByAccount.put(account, dl);
+      dl = loadDestinationList(branch);
+      destinationListByBranch.put(branch, dl);
     }
     return dl;
   }
 
-  protected DestinationList loadDestinationList(Account.Id account)
+  protected DestinationList loadDestinationList(BranchNameKey branch)
       throws ConfigInvalidException, RepositoryNotFoundException, IOException {
-    VersionedAccountDestinations d = VersionedAccountDestinations.forUser(account);
+    VersionedAccountDestinations d = VersionedAccountDestinations.forBranch(branch);
     try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
       d.load(args.allUsersName, git);
     }
@@ -1798,7 +1874,7 @@
   }
 
   /** Returns {@link com.google.gerrit.entities.Account.Id} of the identified calling user. */
-  public Account.Id self() throws QueryParseException {
+  private Account.Id self() throws QueryParseException {
     return args.getIdentifiedUser().getAccountId();
   }
 
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
index 3097224..305316d 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.DynamicOptions.DynamicBean;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
 import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
@@ -40,7 +41,6 @@
 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 com.google.inject.Singleton;
@@ -124,9 +124,16 @@
       int pageSize,
       int pageSizeMultiplier,
       int limit,
+      boolean allowIncompleteResults,
       Set<String> requestedFields) {
     return IndexedChangeQuery.createOptions(
-        indexConfig, start, pageSize, pageSizeMultiplier, limit, requestedFields);
+        indexConfig,
+        start,
+        pageSize,
+        pageSizeMultiplier,
+        limit,
+        allowIncompleteResults,
+        requestedFields);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 62c070c..3c7944c 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.index.query.Predicate.and;
 import static com.google.gerrit.index.query.Predicate.not;
 import static com.google.gerrit.index.query.Predicate.or;
+import static com.google.gerrit.server.query.change.ChangePredicates.EditByPredicateProvider;
 import static com.google.gerrit.server.query.change.ChangeStatusPredicate.open;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -27,6 +28,7 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
@@ -34,6 +36,8 @@
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.InternalQuery;
 import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
@@ -73,12 +77,17 @@
     return ChangeStatusPredicate.forStatus(status);
   }
 
+  private Predicate<ChangeData> editBy(Account.Id accountId) throws QueryParseException {
+    return editByPredicateProvider.editBy(accountId);
+  }
+
   private static Predicate<ChangeData> commit(String id) {
     return ChangePredicates.commitPrefix(id);
   }
 
   private final ChangeData.Factory changeDataFactory;
   private final ChangeNotes.Factory notesFactory;
+  private final EditByPredicateProvider editByPredicateProvider;
 
   @Inject
   InternalChangeQuery(
@@ -86,10 +95,12 @@
       ChangeIndexCollection indexes,
       IndexConfig indexConfig,
       ChangeData.Factory changeDataFactory,
-      ChangeNotes.Factory notesFactory) {
+      ChangeNotes.Factory notesFactory,
+      EditByPredicateProvider editByPredicateProvider) {
     super(queryProcessor, indexes, indexConfig);
     this.changeDataFactory = changeDataFactory;
     this.notesFactory = notesFactory;
+    this.editByPredicateProvider = editByPredicateProvider;
   }
 
   public List<ChangeData> byKey(Change.Key key) {
@@ -113,6 +124,11 @@
     return query(or(preds));
   }
 
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public List<ChangeData> byCustomKeyedValue(String keyValue) {
+    return query(new ChangeIndexPredicate(ChangeField.CUSTOM_KEYED_VALUES_SPEC, keyValue));
+  }
+
   public List<ChangeData> byBranchKey(BranchNameKey branch, Change.Key key) {
     return query(byBranchKeyPred(branch, key));
   }
@@ -153,7 +169,7 @@
     return byCommitsOnBranchNotMergedFromIndex(branch, hashes);
   }
 
-  private Iterable<ChangeData> byCommitsOnBranchNotMergedFromDatabase(
+  private List<ChangeData> byCommitsOnBranchNotMergedFromDatabase(
       Repository repo, BranchNameKey branch, Collection<String> hashes) throws IOException {
     Set<Change.Id> changeIds = Sets.newHashSetWithExpectedSize(hashes.size());
     String lastPrefix = null;
@@ -184,7 +200,7 @@
     return Lists.transform(notes, n -> changeDataFactory.create(n));
   }
 
-  private Iterable<ChangeData> byCommitsOnBranchNotMergedFromIndex(
+  private List<ChangeData> byCommitsOnBranchNotMergedFromIndex(
       BranchNameKey branch, Collection<String> hashes) {
     return query(
         and(
@@ -210,6 +226,10 @@
     return query(and(ChangePredicates.exactTopic(topic), open()));
   }
 
+  public List<ChangeData> byOpenEditByUser(Account.Id accountId) throws QueryParseException {
+    return query(editBy(accountId));
+  }
+
   public List<ChangeData> byCommit(ObjectId id) {
     return byCommit(id.name());
   }
diff --git a/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java b/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
index 74c8d39..e08ff1c 100644
--- a/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
@@ -26,12 +26,12 @@
 import com.google.gerrit.index.query.QueryProcessor;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.account.GroupControl;
 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;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/query/project/ProjectPredicates.java b/java/com/google/gerrit/server/query/project/ProjectPredicates.java
index a7b0743..f2ef497 100644
--- a/java/com/google/gerrit/server/query/project/ProjectPredicates.java
+++ b/java/com/google/gerrit/server/query/project/ProjectPredicates.java
@@ -28,10 +28,18 @@
     return new ProjectPredicate(ProjectField.NAME_SPEC, nameKey.get());
   }
 
+  public static Predicate<ProjectData> prefix(String prefix) {
+    return new ProjectPredicate(ProjectField.PREFIX_NAME_SPEC, prefix);
+  }
+
   public static Predicate<ProjectData> parent(Project.NameKey parentNameKey) {
     return new ProjectPredicate(ProjectField.PARENT_NAME_SPEC, parentNameKey.get());
   }
 
+  public static Predicate<ProjectData> parent2(Project.NameKey parentNameKey) {
+    return new ProjectPredicate(ProjectField.PARENT_NAME_2_SPEC, parentNameKey.get());
+  }
+
   public static Predicate<ProjectData> inname(String name) {
     return new ProjectPredicate(ProjectField.NAME_PART_SPEC, name.toLowerCase(Locale.US));
   }
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
index adba882..f83be1a 100644
--- a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
@@ -26,6 +26,7 @@
  */
 public interface ProjectQueryBuilder {
   String FIELD_LIMIT = "limit";
+  String FIELD_SUBSTRING = "substring";
 
   /** See {@link com.google.gerrit.index.query.QueryBuilder#parse(String)}. */
   Predicate<ProjectData> parse(String query) throws QueryParseException;
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryBuilderImpl.java b/java/com/google/gerrit/server/query/project/ProjectQueryBuilderImpl.java
index 599683e..b15bc6b 100644
--- a/java/com/google/gerrit/server/query/project/ProjectQueryBuilderImpl.java
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryBuilderImpl.java
@@ -19,7 +19,12 @@
 import com.google.common.primitives.Ints;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
 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.project.ProjectIndexCollection;
+import com.google.gerrit.index.project.ProjectSubstringPredicate;
 import com.google.gerrit.index.query.LimitPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
@@ -34,9 +39,12 @@
   private static final QueryBuilder.Definition<ProjectData, ProjectQueryBuilderImpl> mydef =
       new QueryBuilder.Definition<>(ProjectQueryBuilderImpl.class);
 
+  private final ProjectIndex index;
+
   @Inject
-  ProjectQueryBuilderImpl() {
+  ProjectQueryBuilderImpl(ProjectIndexCollection indexes) {
     super(mydef, null);
+    this.index = indexes.getSearchIndex();
   }
 
   @Operator
@@ -45,8 +53,22 @@
   }
 
   @Operator
+  public Predicate<ProjectData> prefix(String prefix) throws QueryParseException {
+    checkOperatorAvailable(ProjectField.PREFIX_NAME_SPEC, "prefix");
+    return ProjectPredicates.prefix(prefix);
+  }
+
+  @Operator
+  public Predicate<ProjectData> substring(String substring) {
+    return new ProjectSubstringPredicate(ProjectQueryBuilder.FIELD_SUBSTRING, substring);
+  }
+
+  @Operator
   public Predicate<ProjectData> parent(String parentName) {
-    return ProjectPredicates.parent(Project.nameKey(parentName));
+    if (!index.getSchema().hasField(ProjectField.PARENT_NAME_2_SPEC)) {
+      return ProjectPredicates.parent(Project.nameKey(parentName));
+    }
+    return ProjectPredicates.parent2(Project.nameKey(parentName));
   }
 
   @Operator
@@ -103,4 +125,17 @@
     }
     return new LimitPredicate<>(FIELD_LIMIT, limit);
   }
+
+  private void checkOperatorAvailable(SchemaField<ProjectData, ?> field, String operator)
+      throws QueryParseException {
+    checkFieldAvailable(
+        field, String.format("'%s' operator is not supported on this gerrit host", operator));
+  }
+
+  private void checkFieldAvailable(SchemaField<ProjectData, ?> field, String errorMessage)
+      throws QueryParseException {
+    if (!index.getSchema().hasField(field)) {
+      throw new QueryParseException(errorMessage);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index dd0ec78d..5a6f75f 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -15,11 +15,13 @@
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/git",
+        "//java/com/google/gerrit/gpg",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
         "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/logging",
@@ -41,5 +43,7 @@
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
+        "//proto:entities_java_proto",
+        "@bcprov//jar",
     ],
 )
diff --git a/java/com/google/gerrit/server/restapi/RestApiModule.java b/java/com/google/gerrit/server/restapi/RestApiModule.java
index dffcf44..73991c9 100644
--- a/java/com/google/gerrit/server/restapi/RestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/RestApiModule.java
@@ -24,6 +24,12 @@
 import com.google.gerrit.server.restapi.project.ProjectRestApiModule;
 import com.google.inject.AbstractModule;
 
+/**
+ * Module to bind REST API endpoints.
+ *
+ * <p>Classes that are needed by the REST layer, but which are not REST API endpoints, should be
+ * bound in {@link RestModule}.
+ */
 public class RestApiModule extends AbstractModule {
   @Override
   protected void configure() {
diff --git a/java/com/google/gerrit/server/restapi/RestModule.java b/java/com/google/gerrit/server/restapi/RestModule.java
new file mode 100644
index 0000000..0dcb4c8
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/RestModule.java
@@ -0,0 +1,133 @@
+// 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.restapi;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.gpg.PublicKeyStore;
+import com.google.gerrit.gpg.UnimplementedPublicKeyStoreProvider;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.UserInitiated;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.change.AddReviewersOp;
+import com.google.gerrit.server.change.AddToAttentionSetOp;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.DeleteChangeOp;
+import com.google.gerrit.server.change.DeleteReviewerByEmailOp;
+import com.google.gerrit.server.change.DeleteReviewerOp;
+import com.google.gerrit.server.change.EmailReviewComments;
+import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.change.RebaseChangeOp;
+import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
+import com.google.gerrit.server.change.ReviewerResource;
+import com.google.gerrit.server.change.SetCherryPickOp;
+import com.google.gerrit.server.change.SetCustomKeyedValuesOp;
+import com.google.gerrit.server.change.SetHashtagsOp;
+import com.google.gerrit.server.change.SetPrivateOp;
+import com.google.gerrit.server.change.SetTopicOp;
+import com.google.gerrit.server.change.WorkInProgressOp;
+import com.google.gerrit.server.comment.CommentContextLoader;
+import com.google.gerrit.server.config.GerritConfigListener;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.project.RefValidationHelper;
+import com.google.gerrit.server.restapi.change.DeleteVoteOp;
+import com.google.gerrit.server.restapi.change.PostReviewOp;
+import com.google.gerrit.server.restapi.change.PreviewFix;
+import com.google.gerrit.server.restapi.project.CreateProject;
+import com.google.gerrit.server.restapi.project.ProjectNode;
+import com.google.gerrit.server.restapi.project.SetParent;
+import com.google.gerrit.server.util.AttentionSetEmail;
+import com.google.gerrit.server.validators.ProjectCreationValidationListener;
+import com.google.inject.Provides;
+import com.google.inject.multibindings.OptionalBinder;
+
+/**
+ * Module to bind classes that are needed but the REST layer, but which are not REST endpoints.
+ *
+ * <p>REST endpoints should be bound in {@link RestApiModule}.
+ */
+public class RestModule extends FactoryModule {
+  @Override
+  protected void configure() {
+    factory(AccountLoader.Factory.class);
+    factory(AddReviewersOp.Factory.class);
+    factory(AddToAttentionSetOp.Factory.class);
+    factory(AttentionSetEmail.Factory.class);
+    factory(ChangeInserter.Factory.class);
+    factory(ChangeResource.Factory.class);
+    factory(CommentContextLoader.Factory.class);
+    factory(DeleteChangeOp.Factory.class);
+    factory(DeleteReviewerByEmailOp.Factory.class);
+    factory(DeleteReviewerOp.Factory.class);
+    factory(DeleteVoteOp.Factory.class);
+    factory(EmailReviewComments.Factory.class);
+    factory(GroupsUpdate.Factory.class);
+    factory(PatchSetInserter.Factory.class);
+    factory(PostReviewOp.Factory.class);
+    factory(PreviewFix.Factory.class);
+    factory(ProjectNode.Factory.class);
+    factory(RebaseChangeOp.Factory.class);
+    factory(RefValidationHelper.Factory.class);
+    factory(RemoveFromAttentionSetOp.Factory.class);
+    factory(ReviewerResource.Factory.class);
+    factory(SetCherryPickOp.Factory.class);
+    factory(SetCustomKeyedValuesOp.Factory.class);
+    factory(SetHashtagsOp.Factory.class);
+    factory(SetPrivateOp.Factory.class);
+    factory(SetTopicOp.Factory.class);
+    factory(WorkInProgressOp.Factory.class);
+
+    DynamicSet.bind(binder(), GerritConfigListener.class).to(SetParent.class);
+    DynamicSet.bind(binder(), ProjectCreationValidationListener.class)
+        .to(CreateProject.ValidBranchListener.class);
+
+    OptionalBinder.newOptionalBinder(binder(), PublicKeyStore.class)
+        .setDefault()
+        .toProvider(UnimplementedPublicKeyStoreProvider.class);
+  }
+
+  @Provides
+  @ServerInitiated
+  AccountsUpdate provideServerInitiatedAccountsUpdate(
+      @AccountsUpdate.AccountsUpdateLoader.WithReindex
+          AccountsUpdate.AccountsUpdateLoader accountsUpdateFactory) {
+    return accountsUpdateFactory.createWithServerIdent();
+  }
+
+  @Provides
+  @UserInitiated
+  AccountsUpdate provideUserInitiatedAccountsUpdate(
+      @AccountsUpdate.AccountsUpdateLoader.WithReindex
+          AccountsUpdate.AccountsUpdateLoader accountsUpdateFactory,
+      IdentifiedUser currentUser) {
+    return accountsUpdateFactory.create(currentUser);
+  }
+
+  @Provides
+  @ServerInitiated
+  GroupsUpdate provideServerInitiatedGroupsUpdate(GroupsUpdate.Factory groupsUpdateFactory) {
+    return groupsUpdateFactory.createWithServerIdent();
+  }
+
+  @Provides
+  @UserInitiated
+  GroupsUpdate provideUserInitiatedGroupsUpdate(
+      GroupsUpdate.Factory groupsUpdateFactory, IdentifiedUser currentUser) {
+    return groupsUpdateFactory.create(currentUser);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java b/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
index 2a8f55f..a09e1bc 100644
--- a/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
@@ -23,105 +23,82 @@
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.ServerInitiated;
-import com.google.gerrit.server.UserInitiated;
-import com.google.gerrit.server.account.AccountsUpdate;
-import com.google.gerrit.server.account.externalids.ExternalIdNotes;
-import com.google.inject.Provides;
 
 public class AccountRestApiModule extends RestApiModule {
   @Override
   protected void configure() {
-    bind(AccountsCollection.class);
+    bind(AccountsCollection.class).to(AccountsCollectionImpl.class);
     bind(Capabilities.class);
+    bind(StarredChanges.Create.class);
 
     DynamicMap.mapOf(binder(), ACCOUNT_KIND);
     DynamicMap.mapOf(binder(), CAPABILITY_KIND);
     DynamicMap.mapOf(binder(), EMAIL_KIND);
     DynamicMap.mapOf(binder(), SSH_KEY_KIND);
-    DynamicMap.mapOf(binder(), STARRED_CHANGE_KIND);
     DynamicMap.mapOf(binder(), STAR_KIND);
+    DynamicMap.mapOf(binder(), STARRED_CHANGE_KIND);
 
     create(ACCOUNT_KIND).to(CreateAccount.class);
     put(ACCOUNT_KIND).to(PutAccount.class);
+    delete(ACCOUNT_KIND).to(DeleteAccount.class);
     get(ACCOUNT_KIND).to(GetAccount.class);
-    get(ACCOUNT_KIND, "detail").to(GetDetail.class);
-    post(ACCOUNT_KIND, "index").to(Index.class);
-    get(ACCOUNT_KIND, "name").to(GetName.class);
-    put(ACCOUNT_KIND, "name").to(PutName.class);
-    delete(ACCOUNT_KIND, "name").to(PutName.class);
-    get(ACCOUNT_KIND, "status").to(GetStatus.class);
-    put(ACCOUNT_KIND, "status").to(PutStatus.class);
-    put(ACCOUNT_KIND, "displayname").to(PutDisplayName.class);
-    get(ACCOUNT_KIND, "username").to(GetUsername.class);
-    put(ACCOUNT_KIND, "username").to(PutUsername.class);
     get(ACCOUNT_KIND, "active").to(GetActive.class);
     put(ACCOUNT_KIND, "active").to(PutActive.class);
     delete(ACCOUNT_KIND, "active").to(DeleteActive.class);
-    child(ACCOUNT_KIND, "emails").to(EmailsCollection.class);
-    create(EMAIL_KIND).to(CreateEmail.class);
-    get(EMAIL_KIND).to(GetEmail.class);
-    put(EMAIL_KIND).to(PutEmail.class);
-    delete(EMAIL_KIND).to(DeleteEmail.class);
-    put(EMAIL_KIND, "preferred").to(PutPreferred.class);
-    put(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class);
-    delete(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class);
-    get(ACCOUNT_KIND, "watched.projects").to(GetWatchedProjects.class);
-    post(ACCOUNT_KIND, "watched.projects").to(PostWatchedProjects.class);
-    post(ACCOUNT_KIND, "watched.projects:delete").to(DeleteWatchedProjects.class);
-
-    child(ACCOUNT_KIND, "sshkeys").to(SshKeys.class);
-    postOnCollection(SSH_KEY_KIND).to(AddSshKey.class);
-    get(SSH_KEY_KIND).to(GetSshKey.class);
-    delete(SSH_KEY_KIND).to(DeleteSshKey.class);
-
+    get(ACCOUNT_KIND, "agreements").to(GetAgreements.class);
+    put(ACCOUNT_KIND, "agreements").to(PutAgreement.class);
     get(ACCOUNT_KIND, "avatar").to(GetAvatar.class);
     get(ACCOUNT_KIND, "avatar.change.url").to(GetAvatarChangeUrl.class);
 
     child(ACCOUNT_KIND, "capabilities").to(Capabilities.class);
+    get(CAPABILITY_KIND).to(GetCapabilities.CheckOne.class);
 
+    put(ACCOUNT_KIND, "displayname").to(PutDisplayName.class);
+    get(ACCOUNT_KIND, "detail").to(GetDetail.class);
+    post(ACCOUNT_KIND, "drafts:delete").to(DeleteDraftComments.class);
+
+    child(ACCOUNT_KIND, "emails").to(EmailsCollection.class);
+    create(EMAIL_KIND).to(CreateEmail.class);
+    delete(EMAIL_KIND).to(DeleteEmail.class);
+    get(EMAIL_KIND).to(GetEmail.class);
+    put(EMAIL_KIND).to(PutEmail.class);
+    put(EMAIL_KIND, "preferred").to(PutPreferred.class);
+
+    get(ACCOUNT_KIND, "external.ids").to(GetExternalIds.class);
+    post(ACCOUNT_KIND, "external.ids:delete").to(DeleteExternalIds.class);
     get(ACCOUNT_KIND, "groups").to(GetGroups.class);
+    post(ACCOUNT_KIND, "index").to(Index.class);
+    get(ACCOUNT_KIND, "name").to(GetName.class);
+    put(ACCOUNT_KIND, "name").to(PutName.class);
+    delete(ACCOUNT_KIND, "name").to(PutName.class);
+    put(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class);
+    delete(ACCOUNT_KIND, "password.http").to(PutHttpPassword.class);
     get(ACCOUNT_KIND, "preferences").to(GetPreferences.class);
     put(ACCOUNT_KIND, "preferences").to(SetPreferences.class);
     get(ACCOUNT_KIND, "preferences.diff").to(GetDiffPreferences.class);
     put(ACCOUNT_KIND, "preferences.diff").to(SetDiffPreferences.class);
     get(ACCOUNT_KIND, "preferences.edit").to(GetEditPreferences.class);
     put(ACCOUNT_KIND, "preferences.edit").to(SetEditPreferences.class);
-    get(CAPABILITY_KIND).to(GetCapabilities.CheckOne.class);
 
-    get(ACCOUNT_KIND, "agreements").to(GetAgreements.class);
-    put(ACCOUNT_KIND, "agreements").to(PutAgreement.class);
+    child(ACCOUNT_KIND, "sshkeys").to(SshKeys.class);
+    postOnCollection(SSH_KEY_KIND).to(AddSshKey.class);
+    get(SSH_KEY_KIND).to(GetSshKey.class);
+    delete(SSH_KEY_KIND).to(DeleteSshKey.class);
 
     child(ACCOUNT_KIND, "starred.changes").to(StarredChanges.class);
     create(STARRED_CHANGE_KIND).to(StarredChanges.Create.class);
     put(STARRED_CHANGE_KIND).to(StarredChanges.Put.class);
     delete(STARRED_CHANGE_KIND).to(StarredChanges.Delete.class);
-    bind(StarredChanges.Create.class);
 
-    get(ACCOUNT_KIND, "external.ids").to(GetExternalIds.class);
-    post(ACCOUNT_KIND, "external.ids:delete").to(DeleteExternalIds.class);
-
-    post(ACCOUNT_KIND, "drafts:delete").to(DeleteDraftComments.class);
+    get(ACCOUNT_KIND, "status").to(GetStatus.class);
+    put(ACCOUNT_KIND, "status").to(PutStatus.class);
+    get(ACCOUNT_KIND, "username").to(GetUsername.class);
+    put(ACCOUNT_KIND, "username").to(PutUsername.class);
+    get(ACCOUNT_KIND, "watched.projects").to(GetWatchedProjects.class);
+    post(ACCOUNT_KIND, "watched.projects").to(PostWatchedProjects.class);
+    post(ACCOUNT_KIND, "watched.projects:delete").to(DeleteWatchedProjects.class);
 
     // The gpgkeys REST endpoints are bound via GpgApiModule.
-
-    factory(AccountsUpdate.Factory.class);
-  }
-
-  @Provides
-  @ServerInitiated
-  AccountsUpdate provideServerInitiatedAccountsUpdate(
-      AccountsUpdate.Factory accountsUpdateFactory, ExternalIdNotes.Factory extIdNotesFactory) {
-    return accountsUpdateFactory.createWithServerIdent(extIdNotesFactory);
-  }
-
-  @Provides
-  @UserInitiated
-  AccountsUpdate provideUserInitiatedAccountsUpdate(
-      AccountsUpdate.Factory accountsUpdateFactory,
-      IdentifiedUser currentUser,
-      ExternalIdNotes.Factory extIdNotesFactory) {
-    return accountsUpdateFactory.create(currentUser, extIdNotesFactory);
+    // The oauthtoken REST endpoint is bound via OAuthRestModule.
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/AccountsCollection.java b/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
index 61ff6b8..fa919df 100644
--- a/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
+++ b/java/com/google/gerrit/server/restapi/account/AccountsCollection.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2012 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.
@@ -21,52 +21,19 @@
 import com.google.gerrit.extensions.restapi.RestCollection;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
 import com.google.gerrit.server.account.AccountResource;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
-@Singleton
-public class AccountsCollection implements RestCollection<TopLevelResource, AccountResource> {
-  private final AccountResolver accountResolver;
-  private final Provider<QueryAccounts> list;
-  private final DynamicMap<RestView<AccountResource>> views;
-
-  @Inject
-  public AccountsCollection(
-      AccountResolver accountResolver,
-      Provider<QueryAccounts> list,
-      DynamicMap<RestView<AccountResource>> views) {
-    this.accountResolver = accountResolver;
-    this.list = list;
-    this.views = views;
-  }
+/** A generic interface for parsing account IDs from URL resources. */
+public interface AccountsCollection extends RestCollection<TopLevelResource, AccountResource> {
+  @Override
+  AccountResource parse(TopLevelResource root, IdString id)
+      throws ResourceNotFoundException, AuthException, IOException, ConfigInvalidException;
 
   @Override
-  public AccountResource parse(TopLevelResource root, IdString id)
-      throws ResourceNotFoundException, AuthException, IOException, ConfigInvalidException {
-    try {
-      return new AccountResource(accountResolver.resolve(id.get()).asUniqueUser());
-    } catch (UnresolvableAccountException e) {
-      if (e.isSelf()) {
-        // Must be authenticated to use 'me' or 'self'.
-        throw new AuthException(e.getMessage(), e);
-      }
-      throw new ResourceNotFoundException(e.getMessage(), e);
-    }
-  }
+  RestView<TopLevelResource> list() throws ResourceNotFoundException;
 
   @Override
-  public RestView<TopLevelResource> list() throws ResourceNotFoundException {
-    return list.get();
-  }
-
-  @Override
-  public DynamicMap<RestView<AccountResource>> views() {
-    return views;
-  }
+  DynamicMap<RestView<AccountResource>> views();
 }
diff --git a/java/com/google/gerrit/server/restapi/account/AccountsCollectionImpl.java b/java/com/google/gerrit/server/restapi/account/AccountsCollectionImpl.java
new file mode 100644
index 0000000..141b01a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/AccountsCollectionImpl.java
@@ -0,0 +1,71 @@
+// 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.restapi.account;
+
+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.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class AccountsCollectionImpl implements AccountsCollection {
+  private final AccountResolver accountResolver;
+  private final Provider<QueryAccounts> list;
+  private final DynamicMap<RestView<AccountResource>> views;
+
+  @Inject
+  public AccountsCollectionImpl(
+      AccountResolver accountResolver,
+      Provider<QueryAccounts> list,
+      DynamicMap<RestView<AccountResource>> views) {
+    this.accountResolver = accountResolver;
+    this.list = list;
+    this.views = views;
+  }
+
+  @Override
+  public AccountResource parse(TopLevelResource root, IdString id)
+      throws ResourceNotFoundException, AuthException, IOException, ConfigInvalidException {
+    try {
+      return new AccountResource(accountResolver.resolve(id.get()).asUniqueUser());
+    } catch (UnresolvableAccountException e) {
+      if (e.isSelf()) {
+        // Must be authenticated to use 'me' or 'self'.
+        throw new AuthException(e.getMessage(), e);
+      }
+      throw new ResourceNotFoundException(e.getMessage(), e);
+    }
+  }
+
+  @Override
+  public RestView<TopLevelResource> list() throws ResourceNotFoundException {
+    return list.get();
+  }
+
+  @Override
+  public DynamicMap<RestView<AccountResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/AddSshKey.java b/java/com/google/gerrit/server/restapi/account/AddSshKey.java
index ff3d5c9..04b046a 100644
--- a/java/com/google/gerrit/server/restapi/account/AddSshKey.java
+++ b/java/com/google/gerrit/server/restapi/account/AddSshKey.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.account;
 
+import static com.google.gerrit.server.mail.EmailFactories.KEY_ADDED;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.flogger.FluentLogger;
@@ -32,7 +33,7 @@
 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.AddKeySender;
+import com.google.gerrit.server.mail.EmailFactories;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -58,7 +59,7 @@
   private final PermissionBackend permissionBackend;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
-  private final AddKeySender.Factory addKeyFactory;
+  private final EmailFactories emailFactories;
 
   @Inject
   AddSshKey(
@@ -66,12 +67,12 @@
       PermissionBackend permissionBackend,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       SshKeyCache sshKeyCache,
-      AddKeySender.Factory addKeyFactory) {
+      EmailFactories emailFactories) {
     this.self = self;
     this.permissionBackend = permissionBackend;
     this.authorizedKeys = authorizedKeys;
     this.sshKeyCache = sshKeyCache;
-    this.addKeyFactory = addKeyFactory;
+    this.emailFactories = emailFactories;
   }
 
   @Override
@@ -109,7 +110,9 @@
       AccountSshKey sshKey = authorizedKeys.addKey(user.getAccountId(), sshPublicKey);
 
       try {
-        addKeyFactory.create(user, sshKey).send();
+        emailFactories
+            .createOutgoingEmail(KEY_ADDED, emailFactories.createAddKeyEmail(user, sshKey))
+            .send();
       } catch (EmailException e) {
         logger.atSevere().withCause(e).log(
             "Cannot send SSH key added message to %s", user.getAccount().preferredEmail());
diff --git a/java/com/google/gerrit/server/restapi/account/CreateAccount.java b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
index 5e283d3..74caf74 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateAccount.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.AccountExternalIdCreator;
 import com.google.gerrit.server.account.AccountLoader;
@@ -50,7 +51,6 @@
 import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.ssh.SshKeyCache;
diff --git a/java/com/google/gerrit/server/restapi/account/CreateEmail.java b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
index dbd1c096..bcebcd1 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateEmail.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.account;
 
 import static com.google.gerrit.extensions.client.AuthType.DEVELOPMENT_BECOME_ANY_ACCOUNT;
+import static com.google.gerrit.server.mail.EmailFactories.NEW_EMAIL_REGISTERED;
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.UsedAt;
@@ -37,9 +38,11 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.mail.EmailFactories;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
-import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
+import com.google.gerrit.server.mail.send.RegisterNewEmailDecorator;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -78,7 +81,7 @@
   private final Realm realm;
   private final PermissionBackend permissionBackend;
   private final AccountManager accountManager;
-  private final RegisterNewEmailSender.Factory registerNewEmailFactory;
+  private final EmailFactories emailFactories;
   private final PutPreferred putPreferred;
   private final OutgoingEmailValidator validator;
   private final MessageIdGenerator messageIdGenerator;
@@ -92,7 +95,7 @@
       PermissionBackend permissionBackend,
       AuthConfig authConfig,
       AccountManager accountManager,
-      RegisterNewEmailSender.Factory registerNewEmailFactory,
+      EmailFactories emailFactories,
       PutPreferred putPreferred,
       OutgoingEmailValidator validator,
       MessageIdGenerator messageIdGenerator,
@@ -101,7 +104,7 @@
     this.realm = realm;
     this.permissionBackend = permissionBackend;
     this.accountManager = accountManager;
-    this.registerNewEmailFactory = registerNewEmailFactory;
+    this.emailFactories = emailFactories;
     this.putPreferred = putPreferred;
     this.validator = validator;
     this.isDevMode = authConfig.getAuthType() == DEVELOPMENT_BECOME_ANY_ACCOUNT;
@@ -172,12 +175,14 @@
       }
     } else {
       try {
-        RegisterNewEmailSender emailSender = registerNewEmailFactory.create(email);
-        if (!emailSender.isAllowed()) {
+        RegisterNewEmailDecorator emailDecorator = emailFactories.createRegisterNewEmail(email);
+        if (!emailDecorator.isAllowed()) {
           throw new MethodNotAllowedException("Not allowed to add email address " + email);
         }
-        emailSender.setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
-        emailSender.send();
+        OutgoingEmail outgoingEmail =
+            emailFactories.createOutgoingEmail(NEW_EMAIL_REGISTERED, emailDecorator);
+        outgoingEmail.setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
+        outgoingEmail.send();
         info.pendingConfirmation = true;
       } catch (EmailException | RuntimeException e) {
         logger.atSevere().withCause(e).log("Cannot send email verification message to %s", email);
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteAccount.java b/java/com/google/gerrit/server/restapi/account/DeleteAccount.java
new file mode 100644
index 0000000..db21ac0
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/DeleteAccount.java
@@ -0,0 +1,196 @@
+// 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.restapi.account;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Table;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.gpg.PublicKeyStoreUtil;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StarredChangesReader;
+import com.google.gerrit.server.StarredChangesWriter;
+import com.google.gerrit.server.UserInitiated;
+import com.google.gerrit.server.account.AccountException;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.change.AccountPatchReviewStore;
+import com.google.gerrit.server.edit.ChangeEdit;
+import com.google.gerrit.server.edit.ChangeEditUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.plugincontext.PluginItemContext;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.ssh.SshKeyCache;
+import com.google.inject.Inject;
+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.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;
+
+/**
+ * REST endpoint for deleting an account.
+ *
+ * <p>This REST endpoint handles {@code DELETE /accounts/<account-identifier>} requests. Currently,
+ * only self deletions are allowed.
+ */
+@Singleton
+public class DeleteAccount implements RestModifyView<AccountResource, Input> {
+  private final Provider<CurrentUser> self;
+  private final Provider<PersonIdent> serverIdent;
+  private final Provider<AccountsUpdate> accountsUpdateProvider;
+  private final VersionedAuthorizedKeys.Accessor authorizedKeys;
+  private final SshKeyCache sshKeyCache;
+  private final StarredChangesReader starredChangesReader;
+  private final StarredChangesWriter starredChangesWriter;
+  private final DeleteDraftCommentsUtil deleteDraftCommentsUtil;
+  private final GitRepositoryManager gitManager;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final ChangeEditUtil changeEditUtil;
+  private final PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore;
+  private final PublicKeyStoreUtil publicKeyStoreUtil;
+
+  @Inject
+  public DeleteAccount(
+      Provider<CurrentUser> self,
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
+      @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
+      VersionedAuthorizedKeys.Accessor authorizedKeys,
+      SshKeyCache sshKeyCache,
+      StarredChangesReader starredChangesReader,
+      StarredChangesWriter starredChangesWriter,
+      DeleteDraftCommentsUtil deleteDraftCommentsUtil,
+      GitRepositoryManager gitManager,
+      Provider<InternalChangeQuery> queryProvider,
+      ChangeEditUtil changeEditUtil,
+      PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore,
+      PublicKeyStoreUtil publicKeyStoreUtil) {
+    this.self = self;
+    this.serverIdent = serverIdent;
+    this.accountsUpdateProvider = accountsUpdateProvider;
+    this.authorizedKeys = authorizedKeys;
+    this.sshKeyCache = sshKeyCache;
+    this.starredChangesReader = starredChangesReader;
+    this.starredChangesWriter = starredChangesWriter;
+    this.deleteDraftCommentsUtil = deleteDraftCommentsUtil;
+    this.gitManager = gitManager;
+    this.queryProvider = queryProvider;
+    this.changeEditUtil = changeEditUtil;
+    this.accountPatchReviewStore = accountPatchReviewStore;
+    this.publicKeyStoreUtil = publicKeyStoreUtil;
+  }
+
+  @Override
+  @CanIgnoreReturnValue
+  public Response<?> apply(AccountResource rsrc, Input unusedInput)
+      throws AuthException, AccountException {
+    IdentifiedUser user = rsrc.getUser();
+    if (!self.get().hasSameAccountId(user)) {
+      throw new AuthException("Delete account is only permitted for self");
+    }
+
+    Account.Id userId = user.getAccountId();
+    try {
+      deletePgpKeys(user);
+      deleteSshKeys(user);
+      deleteStarredChanges(userId);
+      deleteChangeEdits(userId);
+      deleteDraftCommentsUtil.deleteDraftComments(user, null);
+      accountPatchReviewStore.run(a -> a.clearReviewedBy(userId));
+      accountsUpdateProvider
+          .get()
+          .delete("Deleting user through `DELETE /accounts/{ID}`", user.getAccountId());
+    } catch (Exception e) {
+      throw new AccountException("Could not delete account", e);
+    }
+    return Response.none();
+  }
+
+  private void deletePgpKeys(IdentifiedUser user) {
+    if (!publicKeyStoreUtil.hasInitializedPublicKeyStore()) {
+      return;
+    }
+    try {
+      List<RefUpdate.Result> deletedKeyResults =
+          publicKeyStoreUtil.deleteAllPgpKeysForUser(
+              user.getAccountId(), serverIdent.get(), serverIdent.get());
+      for (RefUpdate.Result saveResult : deletedKeyResults) {
+        if (saveResult != RefUpdate.Result.NO_CHANGE
+            && saveResult != RefUpdate.Result.FAST_FORWARD) {
+          throw new StorageException(String.format("Failed to delete PGP key: %s", saveResult));
+        }
+      }
+    } catch (Exception e) {
+      throw new StorageException("Failed to delete PGP keys.", e);
+    }
+  }
+
+  private void deleteSshKeys(IdentifiedUser user) throws ConfigInvalidException, IOException {
+    List<AccountSshKey> keys = authorizedKeys.getKeys(user.getAccountId());
+    for (AccountSshKey key : keys) {
+      authorizedKeys.deleteKey(user.getAccountId(), key.seq());
+    }
+    user.getUserName().ifPresent(sshKeyCache::evict);
+  }
+
+  private void deleteStarredChanges(Account.Id accountId) {
+    ImmutableSet<Change.Id> staredChanges = starredChangesReader.byAccountId(accountId, false);
+    for (Change.Id change : staredChanges) {
+      starredChangesWriter.unstar(self.get().getAccountId(), change);
+    }
+  }
+
+  private void deleteChangeEdits(Account.Id accountId) throws IOException, QueryParseException {
+    // Note: in case of a stale index, the results of this query might be incomplete.
+    List<ChangeData> changesWithEdits = queryProvider.get().byOpenEditByUser(accountId);
+
+    for (ChangeData cd : changesWithEdits) {
+      for (Table.Cell<Account.Id, PatchSet.Id, Ref> edit : cd.editRefs().cellSet()) {
+        if (!accountId.equals(edit.getRowKey())) {
+          continue;
+        }
+        try (Repository repo = gitManager.openRepository(cd.project());
+            RevWalk rw = new RevWalk(repo)) {
+          RevCommit commit = rw.parseCommit(edit.getValue().getObjectId());
+          changeEditUtil.delete(
+              new ChangeEdit(
+                  cd.change(),
+                  edit.getValue().getName(),
+                  commit,
+                  cd.patchSet(edit.getColumnKey())));
+        }
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
index 9e02592..cbbaac5 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
@@ -19,6 +19,7 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.HumanComment;
@@ -31,6 +32,7 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeJson;
@@ -66,6 +68,8 @@
   private final ChangeJson.Factory changeJsonFactory;
   private final Provider<CommentJson> commentJsonProvider;
   private final CommentsUtil commentsUtil;
+  private final DraftCommentsReader draftCommentsReader;
+
   private final PatchSetUtil psUtil;
 
   @Inject
@@ -77,6 +81,7 @@
       ChangeJson.Factory changeJsonFactory,
       Provider<CommentJson> commentJsonProvider,
       CommentsUtil commentsUtil,
+      DraftCommentsReader draftCommentsReader,
       PatchSetUtil psUtil) {
     this.batchUpdateFactory = batchUpdateFactory;
     this.queryBuilder = queryBuilder;
@@ -85,11 +90,13 @@
     this.changeJsonFactory = changeJsonFactory;
     this.commentJsonProvider = commentJsonProvider;
     this.commentsUtil = commentsUtil;
+    this.draftCommentsReader = draftCommentsReader;
     this.psUtil = psUtil;
   }
 
+  @CanIgnoreReturnValue
   public ImmutableList<DeletedDraftCommentInfo> deleteDraftComments(
-      IdentifiedUser user, String query) throws RestApiException, UpdateException {
+      IdentifiedUser user, @Nullable String query) throws RestApiException, UpdateException {
     CommentJson.HumanCommentFormatter humanCommentFormatter =
         commentJsonProvider.get().newHumanCommentFormatter();
     Account.Id accountId = user.getAccountId();
@@ -120,7 +127,7 @@
 
   private Predicate<ChangeData> predicate(Account.Id accountId, String query)
       throws BadRequestException {
-    Predicate<ChangeData> hasDraft = ChangePredicates.draftBy(commentsUtil, accountId);
+    Predicate<ChangeData> hasDraft = ChangePredicates.draftBy(draftCommentsReader, accountId);
     if (CharMatcher.whitespace().trimFrom(Strings.nullToEmpty(query)).isEmpty()) {
       return hasDraft;
     }
@@ -145,7 +152,8 @@
     public boolean updateChange(ChangeContext ctx) throws PermissionBackendException {
       ImmutableList.Builder<CommentInfo> comments = ImmutableList.builder();
       boolean dirty = false;
-      for (HumanComment c : commentsUtil.draftByChangeAuthor(ctx.getNotes(), accountId)) {
+      for (HumanComment c :
+          draftCommentsReader.getDraftsByChangeAndDraftAuthor(ctx.getNotes(), accountId)) {
         dirty = true;
         PatchSet.Id psId = PatchSet.id(ctx.getChange().getId(), c.key.patchSetId);
         commentsUtil.setCommentCommitId(c, ctx.getChange(), psUtil.get(ctx.getNotes(), psId));
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java b/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
index 1574b72..3c32e6a 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.restapi.account;
 
+import static com.google.gerrit.server.mail.EmailFactories.KEY_DELETED;
+
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.common.Input;
@@ -25,7 +27,7 @@
 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.mail.EmailFactories;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -51,7 +53,7 @@
   private final PermissionBackend permissionBackend;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
-  private final DeleteKeySender.Factory deleteKeySenderFactory;
+  private final EmailFactories emailFactories;
 
   @Inject
   DeleteSshKey(
@@ -59,12 +61,12 @@
       PermissionBackend permissionBackend,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       SshKeyCache sshKeyCache,
-      DeleteKeySender.Factory deleteKeySenderFactory) {
+      EmailFactories emailFactories) {
     this.self = self;
     this.permissionBackend = permissionBackend;
     this.authorizedKeys = authorizedKeys;
     this.sshKeyCache = sshKeyCache;
-    this.deleteKeySenderFactory = deleteKeySenderFactory;
+    this.emailFactories = emailFactories;
   }
 
   @Override
@@ -85,7 +87,9 @@
       throws RepositoryNotFoundException, IOException, ConfigInvalidException {
     authorizedKeys.deleteKey(user.getAccountId(), sshKey.seq());
     try {
-      deleteKeySenderFactory.create(user, sshKey).send();
+      emailFactories
+          .createOutgoingEmail(KEY_DELETED, emailFactories.createDeleteKeyEmail(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/PutHttpPassword.java b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
index a43c24e..a3478f7 100644
--- a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
+++ b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.account;
 
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.mail.EmailFactories.PASSWORD_UPDATED;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Strings;
@@ -37,7 +38,7 @@
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.mail.send.HttpPasswordUpdateSender;
+import com.google.gerrit.server.mail.EmailFactories;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -78,7 +79,7 @@
   private final PermissionBackend permissionBackend;
   private final ExternalIds externalIds;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
-  private final HttpPasswordUpdateSender.Factory httpPasswordUpdateSenderFactory;
+  private final EmailFactories emailFactories;
   private final ExternalIdFactory externalIdFactory;
   private final ExternalIdKeyFactory externalIdKeyFactory;
 
@@ -88,14 +89,14 @@
       PermissionBackend permissionBackend,
       ExternalIds externalIds,
       @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
-      HttpPasswordUpdateSender.Factory httpPasswordUpdateSenderFactory,
+      EmailFactories emailFactories,
       ExternalIdFactory externalIdFactory,
       ExternalIdKeyFactory externalIdKeyFactory) {
     this.self = self;
     this.permissionBackend = permissionBackend;
     this.externalIds = externalIds;
     this.accountsUpdateProvider = accountsUpdateProvider;
-    this.httpPasswordUpdateSenderFactory = httpPasswordUpdateSenderFactory;
+    this.emailFactories = emailFactories;
     this.externalIdFactory = externalIdFactory;
     this.externalIdKeyFactory = externalIdKeyFactory;
   }
@@ -152,8 +153,11 @@
                         extId.key(), extId.accountId(), extId.email(), newPassword)));
 
     try {
-      httpPasswordUpdateSenderFactory
-          .create(user, newPassword == null ? "deleted" : "added or updated")
+      emailFactories
+          .createOutgoingEmail(
+              PASSWORD_UPDATED,
+              emailFactories.createHttpPasswordUpdateEmail(
+                  user, newPassword == null ? "deleted" : "added or updated"))
           .send();
     } catch (EmailException e) {
       logger.atSevere().withCause(e).log(
diff --git a/java/com/google/gerrit/server/restapi/account/StarredChanges.java b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
index 8137ec9..95aa07c 100644
--- a/java/com/google/gerrit/server/restapi/account/StarredChanges.java
+++ b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
@@ -20,10 +20,8 @@
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 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;
@@ -35,9 +33,8 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
-import com.google.gerrit.server.StarredChangesUtil.MutuallyExclusiveLabelsException;
+import com.google.gerrit.server.StarredChangesReader;
+import com.google.gerrit.server.StarredChangesWriter;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -55,16 +52,16 @@
 
   private final ChangesCollection changes;
   private final DynamicMap<RestView<AccountResource.StarredChange>> views;
-  private final StarredChangesUtil starredChangesUtil;
+  private final StarredChangesReader starredChangesReader;
 
   @Inject
   StarredChanges(
       ChangesCollection changes,
       DynamicMap<RestView<AccountResource.StarredChange>> views,
-      StarredChangesUtil starredChangesUtil) {
+      StarredChangesReader starredChangesReader) {
     this.changes = changes;
     this.views = views;
-    this.starredChangesUtil = starredChangesUtil;
+    this.starredChangesReader = starredChangesReader;
   }
 
   @Override
@@ -72,9 +69,7 @@
       throws RestApiException, PermissionBackendException, IOException {
     IdentifiedUser user = parent.getUser();
     ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
-    if (starredChangesUtil
-        .getLabels(user.getAccountId(), change.getVirtualId())
-        .contains(StarredChangesUtil.DEFAULT_LABEL)) {
+    if (starredChangesReader.isStarred(user.getAccountId(), change.getVirtualId())) {
       return new AccountResource.StarredChange(user, change);
     }
     throw new ResourceNotFoundException(id);
@@ -100,16 +95,16 @@
       implements RestCollectionCreateView<AccountResource, AccountResource.StarredChange, Input> {
     private final Provider<CurrentUser> self;
     private final ChangesCollection changes;
-    private final StarredChangesUtil starredChangesUtil;
+    private final StarredChangesWriter starredChangesWriter;
 
     @Inject
     Create(
         Provider<CurrentUser> self,
         ChangesCollection changes,
-        StarredChangesUtil starredChangesUtil) {
+        StarredChangesWriter starredChangesWriter) {
       this.self = self;
       this.changes = changes;
-      this.starredChangesUtil = starredChangesUtil;
+      this.starredChangesWriter = starredChangesWriter;
     }
 
     @Override
@@ -130,12 +125,7 @@
       }
 
       try {
-        starredChangesUtil.star(
-            self.get().getAccountId(), change.getVirtualId(), StarredChangesUtil.Operation.ADD);
-      } catch (MutuallyExclusiveLabelsException e) {
-        throw new ResourceConflictException(e.getMessage());
-      } catch (IllegalLabelException e) {
-        throw new BadRequestException(e.getMessage());
+        starredChangesWriter.star(self.get().getAccountId(), change.getVirtualId());
       } catch (DuplicateKeyException e) {
         return Response.none();
       }
@@ -164,22 +154,21 @@
   @Singleton
   public static class Delete implements RestModifyView<AccountResource.StarredChange, Input> {
     private final Provider<CurrentUser> self;
-    private final StarredChangesUtil starredChangesUtil;
+    private final StarredChangesWriter starredChangesWriter;
 
     @Inject
-    Delete(Provider<CurrentUser> self, StarredChangesUtil starredChangesUtil) {
+    Delete(Provider<CurrentUser> self, StarredChangesWriter starredChangesWriter) {
       this.self = self;
-      this.starredChangesUtil = starredChangesUtil;
+      this.starredChangesWriter = starredChangesWriter;
     }
 
     @Override
     public Response<?> apply(AccountResource.StarredChange rsrc, Input in)
-        throws AuthException, IOException, IllegalLabelException {
+        throws AuthException, IOException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed remove starred change");
       }
-      starredChangesUtil.star(
-          self.get().getAccountId(), rsrc.getVirtualId(), StarredChangesUtil.Operation.REMOVE);
+      starredChangesWriter.unstar(self.get().getAccountId(), rsrc.getVirtualId());
       return Response.none();
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyPatch.java b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
index 3177034..672217e 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHANGE_ID;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -48,6 +51,7 @@
 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.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -61,6 +65,7 @@
 import java.time.Instant;
 import java.time.ZoneId;
 import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -83,6 +88,8 @@
   private final PatchSetInserter.Factory patchSetInserterFactory;
   private final Provider<InternalChangeQuery> queryProvider;
   private final ZoneId serverZoneId;
+  private final ProjectCache projectCache;
+  private final ChangeUtil changeUtil;
 
   @Inject
   ApplyPatch(
@@ -93,7 +100,9 @@
       BatchUpdate.Factory batchUpdateFactory,
       PatchSetInserter.Factory patchSetInserterFactory,
       Provider<InternalChangeQuery> queryProvider,
-      @GerritPersonIdent PersonIdent myIdent) {
+      @GerritPersonIdent PersonIdent myIdent,
+      ProjectCache projectCache,
+      ChangeUtil changeUtil) {
     this.jsonFactory = jsonFactory;
     this.contributorAgreements = contributorAgreements;
     this.user = user;
@@ -102,6 +111,8 @@
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.queryProvider = queryProvider;
     this.serverZoneId = myIdent.getZoneId();
+    this.projectCache = projectCache;
+    this.changeUtil = changeUtil;
   }
 
   @Override
@@ -117,6 +128,10 @@
     contributorAgreements.check(project, rsrc.getUser());
     BranchNameKey destBranch = rsrc.getChange().getDest();
 
+    if (input == null || input.patch == null || input.patch.patch == null) {
+      throw new BadRequestException("patch required");
+    }
+
     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
@@ -147,13 +162,18 @@
                 destChange.change().getStatus().name()));
       }
 
-      RevCommit latestPatchset = revWalk.parseCommit(destChange.currentPatchSet().commitId());
+      if (!Strings.isNullOrEmpty(input.base) && Boolean.TRUE.equals(input.amend)) {
+        throw new BadRequestException("amend only works with existing revisions. omit base.");
+      }
 
+      RevCommit latestPatchset = revWalk.parseCommit(destChange.currentPatchSet().commitId());
       RevCommit baseCommit;
+      List<RevCommit> parents;
       if (!Strings.isNullOrEmpty(input.base)) {
         baseCommit =
             CommitUtil.getBaseCommit(
                 project.get(), queryProvider.get(), revWalk, destRef, input.base);
+        parents = ImmutableList.of(baseCommit);
       } else {
         if (latestPatchset.getParentCount() != 1) {
           throw new BadRequestException(
@@ -162,34 +182,42 @@
                       + " %s.",
                   destChange.getId()));
         }
-        baseCommit = revWalk.parseCommit(latestPatchset.getParent(0));
+        if (Boolean.TRUE.equals(input.amend)) {
+          baseCommit = latestPatchset;
+          parents = ImmutableList.copyOf(baseCommit.getParents());
+        } else {
+          baseCommit = revWalk.parseCommit(latestPatchset.getParent(0));
+          parents = ImmutableList.of(baseCommit);
+        }
       }
       PatchApplier.Result applyResult =
           ApplyPatchUtil.applyPatch(repo, oi, input.patch, baseCommit);
       ObjectId treeId = applyResult.getTreeId();
 
       Instant now = TimeUtil.now();
-      PersonIdent committerIdent = user.get().newCommitterIdent(now, serverZoneId);
+      PersonIdent committerIdent =
+          Optional.ofNullable(latestPatchset.getCommitterIdent())
+              .map(
+                  ident ->
+                      user.get()
+                          .newCommitterIdent(ident.getEmailAddress(), now, serverZoneId)
+                          .orElseGet(() -> user.get().newCommitterIdent(now, serverZoneId)))
+              .orElseGet(() -> user.get().newCommitterIdent(now, serverZoneId));
       PersonIdent authorIdent =
           input.author == null
               ? committerIdent
               : new PersonIdent(input.author.name, input.author.email, now, serverZoneId);
-      List<FooterLine> footerLines = latestPatchset.getFooterLines();
-      String messageWithNoFooters =
-          !Strings.isNullOrEmpty(input.commitMessage)
-              ? input.commitMessage
-              : removeFooters(latestPatchset.getFullMessage(), footerLines);
       String commitMessage =
-          ApplyPatchUtil.buildCommitMessage(
-              messageWithNoFooters,
-              footerLines,
-              input.patch.patch,
+          buildFullCommitMessage(
+              project,
+              latestPatchset,
+              input,
               ApplyPatchUtil.getResultPatch(repo, reader, baseCommit, revWalk.lookupTree(treeId)),
               applyResult.getErrors());
 
       ObjectId appliedCommit =
           CommitUtil.createCommitWithTree(
-              oi, authorIdent, committerIdent, baseCommit, commitMessage, treeId);
+              oi, authorIdent, committerIdent, parents, commitMessage, treeId);
       CodeReviewCommit commit = revWalk.parseCommit(appliedCommit);
       oi.flush();
 
@@ -210,6 +238,42 @@
     }
   }
 
+  private String buildFullCommitMessage(
+      NameKey project,
+      RevCommit latestPatchset,
+      ApplyPatchPatchSetInput input,
+      String resultPatch,
+      List<org.eclipse.jgit.patch.PatchApplier.Result.Error> errors)
+      throws ResourceConflictException, BadRequestException {
+    boolean hasInputCommitMessage = !Strings.isNullOrEmpty(input.commitMessage);
+    String fullMessage =
+        hasInputCommitMessage ? input.commitMessage : latestPatchset.getFullMessage();
+    // Since we might add error information to the message, we need to split the footers from the
+    // actual description.
+    List<FooterLine> footerLines = FooterLine.fromMessage(fullMessage);
+    String messageWithNoFooters = removeFooters(fullMessage, footerLines);
+    if (FooterLine.getValues(footerLines, FOOTER_CHANGE_ID).isEmpty()) {
+      footerLines.add(
+          latestPatchset.getFooterLines().stream()
+              .filter(f -> f.matches(FOOTER_CHANGE_ID))
+              .findFirst()
+              .get());
+    }
+    String commitMessage =
+        ApplyPatchUtil.buildCommitMessage(
+            messageWithNoFooters, footerLines, input.patch.patch, resultPatch, errors);
+
+    boolean changeIdRequired =
+        projectCache
+            .get(project)
+            .orElseThrow(illegalState(project))
+            .is(BooleanProjectConfig.REQUIRE_CHANGE_ID);
+    changeUtil.ensureChangeIdIsCorrect(
+        changeIdRequired, changeUtil.getChangeIdsFromFooter(latestPatchset).get(0), commitMessage);
+
+    return commitMessage;
+  }
+
   private static Change insertPatchSet(
       BatchUpdate bu,
       Repository git,
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java b/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
index a5df0f8..c0c5f56 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.common.base.Preconditions.checkNotNull;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.hash.Hashing;
 import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -26,7 +28,6 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
-import java.nio.charset.StandardCharsets;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
@@ -63,7 +64,7 @@
     RevTree tip = mergeTip.getTree();
     Patch patch = new Patch();
     try (InputStream patchStream =
-        new ByteArrayInputStream(decodeIfNecessary(input.patch).getBytes(StandardCharsets.UTF_8))) {
+        new ByteArrayInputStream(decodeIfNecessary(input.patch).getBytes(UTF_8))) {
       patch.parse(patchStream);
       if (!patch.getErrors().isEmpty()) {
         throw new BadRequestException(
@@ -117,6 +118,7 @@
     StringBuilder res = new StringBuilder(message.trim());
 
     boolean appendOriginalPatch = false;
+    boolean appendResultPatch = false;
     String decodedOriginalPatch = decodeIfNecessary(originalPatch);
     if (!errors.isEmpty()) {
       res.append(
@@ -133,6 +135,7 @@
                 + "\nPLEASE REVIEW CAREFULLY.\nDiffs between the patches:\n "
                 + patchDiff.get());
         appendOriginalPatch = true;
+        appendResultPatch = true;
       }
     }
 
@@ -140,9 +143,32 @@
       Optional<String> originalPatchHeader = DiffUtil.getPatchHeader(decodedOriginalPatch);
       String patchDescription =
           (originalPatchHeader.isEmpty() ? decodedOriginalPatch : originalPatchHeader.get()).trim();
-      res.append(
-          "\n\nOriginal patch:\n "
-              + patchDescription.substring(0, Math.min(patchDescription.length(), 1024)));
+      res.append("\n\nOriginal patch:\n ");
+      if (patchDescription.length() <= 1024) {
+        res.append(patchDescription);
+      } else {
+        res.append(
+            patchDescription.substring(0, 1024)
+                + "\n[[[Original patch trimmed due to size. Decoded string size: "
+                + patchDescription.length()
+                + ". Decoded string SHA1: "
+                + Hashing.sha1().hashString(patchDescription, UTF_8)
+                + ".]]]");
+      }
+    }
+    if (appendResultPatch) {
+      res.append("\n\nResult patch:\n ");
+      if (resultPatch.length() <= 1024) {
+        res.append(resultPatch);
+      } else {
+        res.append(
+            resultPatch.substring(0, 1024)
+                + "\n[[[Result patch trimmed due to size. Decoded string size: "
+                + resultPatch.length()
+                + ". Decoded string SHA1: "
+                + Hashing.sha1().hashString(resultPatch, UTF_8)
+                + ".]]]");
+      }
     }
 
     if (!footerLines.isEmpty()) {
@@ -175,17 +201,20 @@
   }
 
   private static Optional<String> verifyAppliedPatch(String originalPatch, String resultPatch) {
-    String cleanOriginalPatch = DiffUtil.cleanPatch(originalPatch);
-    String cleanResultPatch = DiffUtil.cleanPatch(resultPatch);
+    String cleanOriginalPatch = DiffUtil.normalizePatchForComparison(originalPatch);
+    String cleanResultPatch = DiffUtil.normalizePatchForComparison(resultPatch);
     if (cleanOriginalPatch.equals(cleanResultPatch)) {
       return Optional.empty();
     }
-    return Optional.of(StringUtils.difference(cleanOriginalPatch, cleanResultPatch));
+    return Optional.of(
+        StringUtils.difference(
+            cleanOriginalPatch.replaceAll("\n", "\n> "),
+            cleanResultPatch.replaceAll("\n", "\n> ")));
   }
 
   private static String decodeIfNecessary(String patch) {
     if (Base64.isBase64(patch)) {
-      return new String(org.eclipse.jgit.util.Base64.decode(patch), StandardCharsets.UTF_8);
+      return new String(org.eclipse.jgit.util.Base64.decode(patch), UTF_8);
     }
     return patch;
   }
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
index 989ace48..ce78bc7 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -14,12 +14,15 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.entities.Patch.FileMode.EXECUTABLE_FILE;
+import static com.google.gerrit.entities.Patch.FileMode.REGULAR_FILE;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.io.ByteStreams;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
@@ -324,6 +327,23 @@
       return apply(rsrc.getChangeResource(), rsrc.getPath(), fileContentInput);
     }
 
+    @Nullable
+    private Integer decimalAsOctal(Integer inputMode) throws BadRequestException {
+      if (inputMode == null) {
+        return null;
+      }
+
+      switch (inputMode) {
+        case 100755:
+          return EXECUTABLE_FILE.getMode();
+        case 100644:
+          return REGULAR_FILE.getMode();
+      }
+
+      throw new BadRequestException(
+          "file_mode (" + inputMode + ") was invalid: supported values are 100644 or 100755.");
+    }
+
     public Response<Object> apply(
         ChangeResource rsrc, String path, FileContentInput fileContentInput)
         throws AuthException,
@@ -359,17 +379,13 @@
         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, fileContentInput.fileMode);
+            repository,
+            rsrc.getNotes(),
+            path,
+            newContent,
+            decimalAsOctal(fileContentInput.fileMode));
       } 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 33e6342..2ac24c6 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
@@ -29,199 +29,166 @@
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.change.AddReviewersOp;
-import com.google.gerrit.server.change.AddToAttentionSetOp;
-import com.google.gerrit.server.change.ChangeInserter;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.DeleteChangeOp;
-import com.google.gerrit.server.change.DeleteReviewerByEmailOp;
-import com.google.gerrit.server.change.DeleteReviewerOp;
-import com.google.gerrit.server.change.EmailReviewComments;
-import com.google.gerrit.server.change.PatchSetInserter;
-import com.google.gerrit.server.change.RebaseChangeOp;
-import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
-import com.google.gerrit.server.change.ReviewerResource;
-import com.google.gerrit.server.change.SetCherryPickOp;
-import com.google.gerrit.server.change.SetHashtagsOp;
-import com.google.gerrit.server.change.SetPrivateOp;
-import com.google.gerrit.server.change.SetTopicOp;
-import com.google.gerrit.server.change.WorkInProgressOp;
-import com.google.gerrit.server.comment.CommentContextLoader;
 import com.google.gerrit.server.restapi.change.Reviewed.DeleteReviewed;
 import com.google.gerrit.server.restapi.change.Reviewed.PutReviewed;
-import com.google.gerrit.server.util.AttentionSetEmail;
 
 public class ChangeRestApiModule extends RestApiModule {
   @Override
   protected void configure() {
     bind(ChangesCollection.class);
-    bind(Revisions.class);
+    bind(Comments.class);
+    bind(DraftComments.class);
+    bind(Files.class);
+    bind(Fixes.class);
     bind(Reviewers.class);
     bind(RevisionReviewers.class);
-    bind(DraftComments.class);
-    bind(Comments.class);
+    bind(Revisions.class);
     bind(RobotComments.class);
-    bind(Fixes.class);
-    bind(Files.class);
     bind(Votes.class);
 
+    DynamicMap.mapOf(binder(), ATTENTION_SET_ENTRY_KIND);
     DynamicMap.mapOf(binder(), CHANGE_KIND);
+    DynamicMap.mapOf(binder(), CHANGE_EDIT_KIND);
+    DynamicMap.mapOf(binder(), CHANGE_MESSAGE_KIND);
     DynamicMap.mapOf(binder(), COMMENT_KIND);
-    DynamicMap.mapOf(binder(), ROBOT_COMMENT_KIND);
-    DynamicMap.mapOf(binder(), FIX_KIND);
     DynamicMap.mapOf(binder(), DRAFT_COMMENT_KIND);
     DynamicMap.mapOf(binder(), FILE_KIND);
+    DynamicMap.mapOf(binder(), FIX_KIND);
+    DynamicMap.mapOf(binder(), ROBOT_COMMENT_KIND);
     DynamicMap.mapOf(binder(), REVIEWER_KIND);
     DynamicMap.mapOf(binder(), REVISION_KIND);
-    DynamicMap.mapOf(binder(), CHANGE_EDIT_KIND);
     DynamicMap.mapOf(binder(), VOTE_KIND);
-    DynamicMap.mapOf(binder(), CHANGE_MESSAGE_KIND);
-    DynamicMap.mapOf(binder(), ATTENTION_SET_ENTRY_KIND);
 
     postOnCollection(CHANGE_KIND).to(CreateChange.class);
+    delete(CHANGE_KIND).to(DeleteChange.class);
     get(CHANGE_KIND).to(GetChange.class);
-    get(CHANGE_KIND, "meta_diff").to(GetMetaDiff.class);
-    post(CHANGE_KIND, "merge").to(CreateMergePatchSet.class);
-    get(CHANGE_KIND, "detail").to(GetDetail.class);
-    get(CHANGE_KIND, "topic").to(GetTopic.class);
-    get(CHANGE_KIND, "in").to(ChangeIncludedIn.class);
+    post(CHANGE_KIND, "abandon").to(Abandon.class);
+
     child(CHANGE_KIND, "attention").to(AttentionSet.class);
+    postOnCollection(ATTENTION_SET_ENTRY_KIND).to(AddToAttentionSet.class);
     delete(ATTENTION_SET_ENTRY_KIND).to(RemoveFromAttentionSet.class);
     post(ATTENTION_SET_ENTRY_KIND, "delete").to(RemoveFromAttentionSet.class);
-    postOnCollection(ATTENTION_SET_ENTRY_KIND).to(AddToAttentionSet.class);
-    get(CHANGE_KIND, "hashtags").to(GetHashtags.class);
-    get(CHANGE_KIND, "comments").to(ListChangeComments.class);
-    get(CHANGE_KIND, "robotcomments").to(ListChangeRobotComments.class);
-    get(CHANGE_KIND, "drafts").to(ListChangeDrafts.class);
+
     get(CHANGE_KIND, "check").to(Check.class);
-    get(CHANGE_KIND, "pure_revert").to(GetPureRevert.class);
     post(CHANGE_KIND, "check").to(Check.class);
-    put(CHANGE_KIND, "topic").to(PutTopic.class);
-    delete(CHANGE_KIND, "topic").to(PutTopic.class);
-    delete(CHANGE_KIND).to(DeleteChange.class);
-    post(CHANGE_KIND, "abandon").to(Abandon.class);
-    post(CHANGE_KIND, "hashtags").to(PostHashtags.class);
-    post(CHANGE_KIND, "restore").to(Restore.class);
-    post(CHANGE_KIND, "revert").to(Revert.class);
-    post(CHANGE_KIND, "revert_submission").to(RevertSubmission.class);
-    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);
-    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);
-    postOnCollection(REVIEWER_KIND).to(PostReviewers.class);
-    get(REVIEWER_KIND).to(GetReviewer.class);
-    delete(REVIEWER_KIND).to(DeleteReviewer.class);
-    post(REVIEWER_KIND, "delete").to(DeleteReviewer.class);
-    child(REVIEWER_KIND, "votes").to(Votes.class);
-    delete(VOTE_KIND).to(DeleteVote.class);
-    post(VOTE_KIND, "delete").to(DeleteVote.class);
-
-    child(CHANGE_KIND, "revisions").to(Revisions.class);
-    get(REVISION_KIND, "actions").to(GetRevisionActions.class);
-    post(REVISION_KIND, "cherrypick").to(CherryPick.class);
-    get(REVISION_KIND, "commit").to(GetCommit.class);
-    get(REVISION_KIND, "mergeable").to(Mergeable.class);
-    get(REVISION_KIND, "related").to(GetRelated.class);
-    get(REVISION_KIND, "review").to(GetReview.class);
-    post(REVISION_KIND, "review").to(PostReview.class);
-    post(REVISION_KIND, "submit").to(Submit.class);
-    post(REVISION_KIND, "rebase").to(Rebase.class);
-    put(REVISION_KIND, "description").to(PutDescription.class);
-    get(REVISION_KIND, "description").to(GetDescription.class);
-    get(REVISION_KIND, "patch").to(GetPatch.class);
-    get(REVISION_KIND, "submit_type").to(TestSubmitType.Get.class);
-    post(REVISION_KIND, "test.submit_rule").to(TestSubmitRule.class);
-    post(REVISION_KIND, "test.submit_type").to(TestSubmitType.class);
-    get(REVISION_KIND, "archive").to(GetArchive.class);
-    get(REVISION_KIND, "mergelist").to(GetMergeList.class);
-
-    child(REVISION_KIND, "reviewers").to(RevisionReviewers.class);
-
-    child(REVISION_KIND, "drafts").to(DraftComments.class);
-    put(REVISION_KIND, "drafts").to(CreateDraftComment.class);
-    get(DRAFT_COMMENT_KIND).to(GetDraftComment.class);
-    put(DRAFT_COMMENT_KIND).to(PutDraftComment.class);
-    delete(DRAFT_COMMENT_KIND).to(DeleteDraftComment.class);
-
-    child(REVISION_KIND, "comments").to(Comments.class);
-    get(COMMENT_KIND).to(GetComment.class);
-    delete(COMMENT_KIND).to(DeleteComment.class);
-    post(COMMENT_KIND, "delete").to(DeleteComment.class);
-
-    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(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);
-
-    child(REVISION_KIND, "files").to(Files.class);
-    put(FILE_KIND, "reviewed").to(PutReviewed.class);
-    delete(FILE_KIND, "reviewed").to(DeleteReviewed.class);
-    get(FILE_KIND, "content").to(GetContent.class);
-    get(FILE_KIND, "download").to(DownloadContent.class);
-    get(FILE_KIND, "diff").to(GetDiff.class);
-    get(FILE_KIND, "blame").to(GetBlame.class);
+    get(CHANGE_KIND, "comments").to(ListChangeComments.class);
+    get(CHANGE_KIND, "custom_keyed_values").to(GetCustomKeyedValues.class);
+    post(CHANGE_KIND, "custom_keyed_values").to(PostCustomKeyedValues.class);
+    get(CHANGE_KIND, "detail").to(GetDetail.class);
+    get(CHANGE_KIND, "drafts").to(ListChangeDrafts.class);
 
     child(CHANGE_KIND, "edit").to(ChangeEdits.class);
     create(CHANGE_EDIT_KIND).to(ChangeEdits.Create.class);
+    delete(CHANGE_EDIT_KIND).to(ChangeEdits.DeleteContent.class);
+    deleteOnCollection(CHANGE_EDIT_KIND).to(DeleteChangeEdit.class);
     deleteMissing(CHANGE_EDIT_KIND).to(ChangeEdits.DeleteFile.class);
     postOnCollection(CHANGE_EDIT_KIND).to(ChangeEdits.Post.class);
-    deleteOnCollection(CHANGE_EDIT_KIND).to(DeleteChangeEdit.class);
-    post(CHANGE_KIND, "edit:publish").to(PublishChangeEdit.class);
-    post(CHANGE_KIND, "edit:rebase").to(RebaseChangeEdit.class);
-    put(CHANGE_KIND, "edit:message").to(ChangeEdits.EditMessage.class);
-    get(CHANGE_KIND, "edit:message").to(ChangeEdits.GetMessage.class);
-    put(CHANGE_EDIT_KIND, "/").to(ChangeEdits.Put.class);
-    delete(CHANGE_EDIT_KIND).to(ChangeEdits.DeleteContent.class);
     get(CHANGE_EDIT_KIND, "/").to(ChangeEdits.Get.class);
+    put(CHANGE_EDIT_KIND, "/").to(ChangeEdits.Put.class);
     get(CHANGE_EDIT_KIND, "meta").to(ChangeEdits.GetMeta.class);
 
+    put(CHANGE_KIND, "edit:message").to(ChangeEdits.EditMessage.class);
+    get(CHANGE_KIND, "edit:message").to(ChangeEdits.GetMessage.class);
+    post(CHANGE_KIND, "edit:publish").to(PublishChangeEdit.class);
+    post(CHANGE_KIND, "edit:rebase").to(RebaseChangeEdit.class);
+    get(CHANGE_KIND, "hashtags").to(GetHashtags.class);
+    post(CHANGE_KIND, "hashtags").to(PostHashtags.class);
+    get(CHANGE_KIND, "in").to(ChangeIncludedIn.class);
+    post(CHANGE_KIND, "index").to(Index.class);
+    get(CHANGE_KIND, "meta_diff").to(GetMetaDiff.class);
+    post(CHANGE_KIND, "merge").to(CreateMergePatchSet.class);
+    put(CHANGE_KIND, "message").to(PutMessage.class);
+
     child(CHANGE_KIND, "messages").to(ChangeMessages.class);
-    get(CHANGE_MESSAGE_KIND).to(GetChangeMessage.class);
     delete(CHANGE_MESSAGE_KIND).to(DeleteChangeMessage.DefaultDeleteChangeMessage.class);
+    get(CHANGE_MESSAGE_KIND).to(GetChangeMessage.class);
     post(CHANGE_MESSAGE_KIND, "delete").to(DeleteChangeMessage.class);
 
-    factory(AccountLoader.Factory.class);
-    factory(ChangeInserter.Factory.class);
-    factory(ChangeResource.Factory.class);
-    factory(CommentContextLoader.Factory.class);
-    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(SetCherryPickOp.Factory.class);
-    factory(SetHashtagsOp.Factory.class);
-    factory(SetTopicOp.Factory.class);
-    factory(SetPrivateOp.Factory.class);
-    factory(WorkInProgressOp.Factory.class);
-    factory(AddToAttentionSetOp.Factory.class);
-    factory(RemoveFromAttentionSetOp.Factory.class);
-    factory(AttentionSetEmail.Factory.class);
+    post(CHANGE_KIND, "move").to(Move.class);
+    post(CHANGE_KIND, "patch:apply").to(ApplyPatch.class);
+    post(CHANGE_KIND, "private").to(PostPrivate.class);
+    post(CHANGE_KIND, "private.delete").to(DeletePrivateByPost.class);
+    delete(CHANGE_KIND, "private").to(DeletePrivate.class);
+    get(CHANGE_KIND, "pure_revert").to(GetPureRevert.class);
+    post(CHANGE_KIND, "ready").to(SetReadyForReview.class);
+    post(CHANGE_KIND, "rebase").to(Rebase.CurrentRevision.class);
+    post(CHANGE_KIND, "rebase:chain").to(RebaseChain.class);
+    post(CHANGE_KIND, "restore").to(Restore.class);
+    post(CHANGE_KIND, "revert").to(Revert.class);
+    post(CHANGE_KIND, "revert_submission").to(RevertSubmission.class);
+
+    child(CHANGE_KIND, "reviewers").to(Reviewers.class);
+    postOnCollection(REVIEWER_KIND).to(PostReviewers.class);
+    delete(REVIEWER_KIND).to(DeleteReviewer.class);
+    get(REVIEWER_KIND).to(GetReviewer.class);
+    post(REVIEWER_KIND, "delete").to(DeleteReviewer.class);
+    child(REVIEWER_KIND, "votes").to(Votes.class);
+
+    child(CHANGE_KIND, "revisions").to(Revisions.class);
+    get(REVISION_KIND, "actions").to(GetRevisionActions.class);
+    get(REVISION_KIND, "archive").to(GetArchive.class);
+    post(REVISION_KIND, "cherrypick").to(CherryPick.class);
+
+    child(REVISION_KIND, "comments").to(Comments.class);
+    delete(COMMENT_KIND).to(DeleteComment.class);
+    get(COMMENT_KIND).to(GetComment.class);
+    post(COMMENT_KIND, "delete").to(DeleteComment.class);
+
+    get(REVISION_KIND, "commit").to(GetCommit.class);
+    get(REVISION_KIND, "description").to(GetDescription.class);
+    put(REVISION_KIND, "description").to(PutDescription.class);
+
+    child(REVISION_KIND, "drafts").to(DraftComments.class);
+    put(REVISION_KIND, "drafts").to(CreateDraftComment.class);
+    delete(DRAFT_COMMENT_KIND).to(DeleteDraftComment.class);
+    get(DRAFT_COMMENT_KIND).to(GetDraftComment.class);
+    put(DRAFT_COMMENT_KIND).to(PutDraftComment.class);
+
+    child(REVISION_KIND, "files").to(Files.class);
+    get(FILE_KIND, "blame").to(GetBlame.class);
+    get(FILE_KIND, "content").to(GetContent.class);
+    get(FILE_KIND, "diff").to(GetDiff.class);
+    get(FILE_KIND, "download").to(DownloadContent.class);
+    put(FILE_KIND, "reviewed").to(PutReviewed.class);
+    delete(FILE_KIND, "reviewed").to(DeleteReviewed.class);
+
+    child(REVISION_KIND, "fixes").to(Fixes.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, "mergeable").to(Mergeable.class);
+    get(REVISION_KIND, "mergelist").to(GetMergeList.class);
+    get(REVISION_KIND, "patch").to(GetPatch.class);
+    get(REVISION_KIND, "ported_comments").to(ListPortedComments.class);
+    get(REVISION_KIND, "ported_drafts").to(ListPortedDrafts.class);
+    post(REVISION_KIND, "rebase").to(Rebase.class);
+    get(REVISION_KIND, "related").to(GetRelated.class);
+    get(REVISION_KIND, "review").to(GetReview.class);
+    post(REVISION_KIND, "review").to(PostReview.class);
+    child(REVISION_KIND, "reviewers").to(RevisionReviewers.class);
+
+    child(REVISION_KIND, "robotcomments").to(RobotComments.class);
+    get(ROBOT_COMMENT_KIND).to(GetRobotComment.class);
+
+    post(REVISION_KIND, "submit").to(Submit.class);
+    get(REVISION_KIND, "submit_type").to(TestSubmitType.Get.class);
+    post(REVISION_KIND, "test.submit_rule").to(TestSubmitRule.class);
+    post(REVISION_KIND, "test.submit_type").to(TestSubmitType.class);
+
+    get(CHANGE_KIND, "robotcomments").to(ListChangeRobotComments.class);
+    delete(CHANGE_KIND, "topic").to(PutTopic.class);
+    get(CHANGE_KIND, "topic").to(GetTopic.class);
+    put(CHANGE_KIND, "topic").to(PutTopic.class);
+    post(CHANGE_KIND, "submit").to(Submit.CurrentRevision.class);
+    get(CHANGE_KIND, "submitted_together").to(SubmittedTogether.class);
+    get(CHANGE_KIND, "suggest_reviewers").to(SuggestChangeReviewers.class);
+
+    delete(VOTE_KIND).to(DeleteVote.class);
+    post(VOTE_KIND, "delete").to(DeleteVote.class);
+
+    post(CHANGE_KIND, "wip").to(SetWorkInProgress.class);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 210fd12..c8e80f5 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.NotifyResolver;
@@ -52,7 +53,6 @@
 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;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -74,6 +74,7 @@
 import java.time.ZoneId;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -178,7 +179,8 @@
         null,
         null,
         null,
-        null);
+        null,
+        Optional.empty());
   }
 
   /**
@@ -213,7 +215,17 @@
           ConfigInvalidException,
           NoSuchProjectException {
     return cherryPick(
-        sourceChange, project, sourceCommit, input, dest, TimeUtil.now(), null, null, null, null);
+        sourceChange,
+        project,
+        sourceCommit,
+        input,
+        dest,
+        TimeUtil.now(),
+        null,
+        null,
+        null,
+        null,
+        Optional.empty());
   }
 
   /**
@@ -235,13 +247,17 @@
    * @param idForNewChange The ID that the new change of the cherry pick will have. If provided and
    *     the cherry-pick doesn't result in creating a new change, then
    *     InvalidChangeOperationException is thrown.
+   * @param verifiedBaseCommit - base commit for the cherry-pick, which is guaranteed to be
+   *     associated with exactly one change and belong to a {@code dest} branch. This is currently
+   *     only used when this base commit was created in the same API call.
    * @return Result object that describes the cherry pick.
    * @throws IOException Unable to open repository or read from the database.
    * @throws InvalidChangeOperationException Parent or branch don't exist, or two changes with same
    *     key exist in the branch. Also thrown when idForNewChange is not null but cherry-pick only
    *     creates a new patchset rather than a new change.
    * @throws UpdateException Problem updating the database using batchUpdateFactory.
-   * @throws RestApiException Error such as invalid SHA1
+   * @throws RestApiException Error such as invalid SHA1, or {@code input.committerEmail} is not
+   *     among the registered emails of the current user.
    * @throws ConfigInvalidException Can't find account to notify.
    * @throws NoSuchProjectException Can't find project state.
    */
@@ -255,7 +271,8 @@
       @Nullable Change.Id revertedChange,
       @Nullable ObjectId changeIdForNewChange,
       @Nullable Change.Id idForNewChange,
-      @Nullable Boolean workInProgress)
+      @Nullable Boolean workInProgress,
+      Optional<RevCommit> verifiedBaseCommit)
       throws IOException,
           InvalidChangeOperationException,
           UpdateException,
@@ -276,9 +293,14 @@
             String.format("Branch %s does not exist.", dest.branch()));
       }
 
-      RevCommit baseCommit =
-          CommitUtil.getBaseCommit(
-              project.get(), queryProvider.get(), revWalk, destRef, input.base);
+      RevCommit baseCommit;
+      if (verifiedBaseCommit.isPresent()) {
+        baseCommit = verifiedBaseCommit.get();
+      } else {
+        baseCommit =
+            CommitUtil.getBaseCommit(
+                project.get(), queryProvider.get(), revWalk, destRef, input.base);
+      }
 
       CodeReviewCommit commitToCherryPick = revWalk.parseCommit(sourceCommit);
 
@@ -318,7 +340,30 @@
       CodeReviewCommit cherryPickCommit;
       ProjectState projectState =
           projectCache.get(dest.project()).orElseThrow(noSuchProject(dest.project()));
-      PersonIdent committerIdent = identifiedUser.newCommitterIdent(timestamp, serverZoneId);
+
+      PersonIdent committerIdent;
+      if (input.committerEmail == null) {
+        committerIdent =
+            Optional.ofNullable(commitToCherryPick.getCommitterIdent())
+                .map(
+                    ident ->
+                        identifiedUser
+                            .newCommitterIdent(ident.getEmailAddress(), timestamp, serverZoneId)
+                            .orElseGet(
+                                () -> identifiedUser.newCommitterIdent(timestamp, serverZoneId)))
+                .orElseGet(() -> identifiedUser.newCommitterIdent(timestamp, serverZoneId));
+      } else {
+        committerIdent =
+            identifiedUser
+                .newCommitterIdent(input.committerEmail, timestamp, serverZoneId)
+                .orElseThrow(
+                    () ->
+                        new BadRequestException(
+                            String.format(
+                                "Cannot cherry-pick using committer email %s, "
+                                    + "as it is not among the registered emails of account %s",
+                                input.committerEmail, identifiedUser.getAccountId().get())));
+      }
 
       try {
         MergeUtil mergeUtil;
@@ -369,6 +414,7 @@
                     destChange.notes(),
                     cherryPickCommit,
                     sourceChange,
+                    sourceCommit,
                     newTopic,
                     input,
                     workInProgress);
@@ -402,6 +448,7 @@
       ChangeNotes destNotes,
       CodeReviewCommit cherryPickCommit,
       @Nullable Change sourceChange,
+      @Nullable ObjectId sourceCommit,
       String topic,
       CherryPickInput input,
       @Nullable Boolean workInProgress)
@@ -409,13 +456,20 @@
     Change destChange = destNotes.getChange();
     PatchSet.Id psId = ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
     PatchSetInserter inserter = patchSetInserterFactory.create(destNotes, psId, cherryPickCommit);
-    inserter.setMessage("Uploaded patch set " + inserter.getPatchSetId().get() + ".");
+    BranchNameKey sourceBranch = sourceChange == null ? null : sourceChange.getDest();
+    inserter.setMessage(
+        messageForDestinationChange(
+            inserter.getPatchSetId(), sourceBranch, sourceCommit, cherryPickCommit));
     inserter.setTopic(topic);
     if (workInProgress != null) {
       inserter.setWorkInProgress(workInProgress);
-    }
-    if (shouldSetToReady(cherryPickCommit, destNotes, workInProgress)) {
-      inserter.setWorkInProgress(false);
+    } else {
+      boolean shouldSetToWIP =
+          (sourceChange != null && sourceChange.isWorkInProgress())
+              || !cherryPickCommit.getFilesWithGitConflicts().isEmpty();
+      if (shouldSetToWIP != destNotes.getChange().isWorkInProgress()) {
+        inserter.setWorkInProgress(shouldSetToWIP);
+      }
     }
     inserter.setValidationOptions(
         ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions));
@@ -432,20 +486,6 @@
     return destChange.getId();
   }
 
-  /**
-   * We should set the change to be "ready for review" if: 1. workInProgress is not already set on
-   * this request. 2. The patch-set doesn't have any git conflict markers. 3. The change used to be
-   * work in progress (because of a previous patch-set).
-   */
-  private boolean shouldSetToReady(
-      CodeReviewCommit cherryPickCommit,
-      ChangeNotes destChangeNotes,
-      @Nullable Boolean workInProgress) {
-    return workInProgress == null
-        && cherryPickCommit.getFilesWithGitConflicts().isEmpty()
-        && destChangeNotes.getChange().isWorkInProgress();
-  }
-
   private Change.Id createNewChange(
       BatchUpdate bu,
       CodeReviewCommit cherryPickCommit,
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index 8ebe71f..2d0c739 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -44,6 +44,7 @@
 import com.google.gerrit.server.comment.CommentContextKey;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
@@ -52,8 +53,8 @@
 
 public class CommentJson {
 
-  private final AccountLoader.Factory accountLoaderFactory;
-  private final CommentContextCache commentContextCache;
+  private final Provider<AccountLoader.Factory> accountLoaderFactory;
+  private final Provider<CommentContextCache> commentContextCache;
 
   private Project.NameKey project;
   private Change.Id changeId;
@@ -64,37 +65,39 @@
   private int contextPadding;
 
   @Inject
-  CommentJson(AccountLoader.Factory accountLoaderFactory, CommentContextCache commentContextCache) {
+  CommentJson(
+      Provider<AccountLoader.Factory> accountLoaderFactory,
+      Provider<CommentContextCache> commentContextCache) {
     this.accountLoaderFactory = accountLoaderFactory;
     this.commentContextCache = commentContextCache;
   }
 
-  CommentJson setFillAccounts(boolean fillAccounts) {
+  public CommentJson setFillAccounts(boolean fillAccounts) {
     this.fillAccounts = fillAccounts;
     return this;
   }
 
-  CommentJson setFillPatchSet(boolean fillPatchSet) {
+  public CommentJson setFillPatchSet(boolean fillPatchSet) {
     this.fillPatchSet = fillPatchSet;
     return this;
   }
 
-  CommentJson setFillCommentContext(boolean fillCommentContext) {
+  public CommentJson setFillCommentContext(boolean fillCommentContext) {
     this.fillCommentContext = fillCommentContext;
     return this;
   }
 
-  CommentJson setContextPadding(int contextPadding) {
+  public CommentJson setContextPadding(int contextPadding) {
     this.contextPadding = contextPadding;
     return this;
   }
 
-  CommentJson setProjectKey(Project.NameKey project) {
+  public CommentJson setProjectKey(Project.NameKey project) {
     this.project = project;
     return this;
   }
 
-  CommentJson setChangeId(Change.Id changeId) {
+  public CommentJson setChangeId(Change.Id changeId) {
     this.changeId = changeId;
     return this;
   }
@@ -109,7 +112,7 @@
 
   private abstract class BaseCommentFormatter<F extends Comment, T extends CommentInfo> {
     public T format(F comment) throws PermissionBackendException {
-      AccountLoader loader = fillAccounts ? accountLoaderFactory.create(true) : null;
+      AccountLoader loader = fillAccounts ? accountLoaderFactory.get().create(true) : null;
       T info = toInfo(comment, loader);
       if (loader != null) {
         loader.fill();
@@ -118,7 +121,7 @@
     }
 
     public Map<String, List<T>> format(Iterable<F> comments) throws PermissionBackendException {
-      AccountLoader loader = fillAccounts ? accountLoaderFactory.create(true) : null;
+      AccountLoader loader = fillAccounts ? accountLoaderFactory.get().create(true) : null;
 
       Map<String, List<T>> out = new TreeMap<>();
 
@@ -147,7 +150,7 @@
     }
 
     public ImmutableList<T> formatAsList(Iterable<F> comments) throws PermissionBackendException {
-      AccountLoader loader = fillAccounts ? accountLoaderFactory.create(true) : null;
+      AccountLoader loader = fillAccounts ? accountLoaderFactory.get().create(true) : null;
 
       ImmutableList<T> out =
           Streams.stream(comments)
@@ -169,7 +172,8 @@
     protected void addCommentContext(List<T> allComments) {
       List<CommentContextKey> keys =
           allComments.stream().map(this::createCommentContextKey).collect(toList());
-      ImmutableMap<CommentContextKey, CommentContext> allContext = commentContextCache.getAll(keys);
+      ImmutableMap<CommentContextKey, CommentContext> allContext =
+          commentContextCache.get().getAll(keys);
       for (T c : allComments) {
         CommentContextKey contextKey = createCommentContextKey(c);
         CommentContext commentContext = allContext.get(contextKey);
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index 788492e..e597607 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -22,6 +22,7 @@
 import com.google.common.base.Strings;
 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.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
@@ -55,6 +56,7 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeJson;
@@ -69,7 +71,6 @@
 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;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -431,7 +432,12 @@
           c =
               rw.parseCommit(
                   CommitUtil.createCommitWithTree(
-                      oi, author, committer, mergeTip, appliedPatchCommitMessage, treeId));
+                      oi,
+                      author,
+                      committer,
+                      ImmutableList.of(mergeTip),
+                      appliedPatchCommitMessage,
+                      treeId));
         } else {
           // create an empty commit.
           c = createEmptyCommit(oi, rw, author, committer, mergeTip, commitMessage);
@@ -457,6 +463,15 @@
           ins.setValidationOptions(validationOptions.build());
         }
 
+        if (input.customKeyedValues != null) {
+          ImmutableMap.Builder<String, String> customKeyedValues = ImmutableMap.builder();
+          input
+              .customKeyedValues
+              .entrySet()
+              .forEach(e -> customKeyedValues.put(e.getKey(), e.getValue()));
+          ins.setCustomKeyedValues(customKeyedValues.build());
+        }
+
         try (BatchUpdate bu = updateFactory.create(projectState.getNameKey(), me, now)) {
           bu.setRepository(git, rw, oi);
           bu.setNotify(
@@ -552,7 +567,8 @@
           }
           parentCommit = null;
         } else {
-          throw new BadRequestException("Destination branch does not exist");
+          throw new BadRequestException(
+              String.format("Destination branch does not exist %s", inputBranch));
         }
       }
     }
@@ -597,14 +613,15 @@
       CodeReviewRevWalk rw,
       PersonIdent authorIdent,
       PersonIdent committerIdent,
-      RevCommit mergeTip,
+      @Nullable RevCommit mergeTip,
       String commitMessage)
       throws IOException {
     logger.atFine().log("Creating empty commit");
     ObjectId treeID = mergeTip == null ? emptyTreeId(oi) : mergeTip.getTree().getId();
+    List<RevCommit> parents = mergeTip == null ? ImmutableList.of() : ImmutableList.of(mergeTip);
     return rw.parseCommit(
         CommitUtil.createCommitWithTree(
-            oi, authorIdent, committerIdent, mergeTip, commitMessage, treeID));
+            oi, authorIdent, committerIdent, parents, commitMessage, treeID));
   }
 
   private static ObjectId emptyTreeId(ObjectInserter inserter) throws IOException {
diff --git a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
index cd0025f..8849c82 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
@@ -18,6 +18,8 @@
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.DraftInput;
@@ -29,20 +31,33 @@
 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.CommentForValidation.CommentSource;
+import com.google.gerrit.extensions.validators.CommentForValidation.CommentType;
+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.CommentsUtil;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.PublishCommentUtil;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 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.UpdateException;
 import com.google.gerrit.server.update.context.RefUpdateContext;
 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.time.Instant;
 import java.util.Collections;
+import java.util.stream.Collectors;
 
 @Singleton
 public class CreateDraftComment implements RestModifyView<RevisionResource, DraftInput> {
@@ -50,17 +65,23 @@
   private final Provider<CommentJson> commentJson;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final PluginSetContext<CommentValidator> commentValidators;
 
   @Inject
   CreateDraftComment(
       BatchUpdate.Factory updateFactory,
       Provider<CommentJson> commentJson,
       CommentsUtil commentsUtil,
-      PatchSetUtil psUtil) {
+      PatchSetUtil psUtil,
+      ChangeNotes.Factory changeNotesFactory,
+      PluginSetContext<CommentValidator> commentValidators) {
     this.updateFactory = updateFactory;
     this.commentJson = commentJson;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
+    this.changeNotesFactory = changeNotesFactory;
+    this.commentValidators = commentValidators;
   }
 
   @Override
@@ -83,6 +104,7 @@
       throw new BadRequestException(
           String.format("Invalid inReplyTo, comment %s not found", in.inReplyTo));
     }
+    validateDraftComment(rsrc, in, changeNotesFactory, commentValidators, commentsUtil);
     try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
       try (BatchUpdate bu =
           updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
@@ -95,6 +117,79 @@
     }
   }
 
+  static void validateDraftComment(
+      RevisionResource rsrc,
+      DraftInput in,
+      ChangeNotes.Factory changeNotesFactory,
+      PluginSetContext<CommentValidator> commentValidators,
+      CommentsUtil commentsUtil)
+      throws BadRequestException {
+    HumanComment comment =
+        createDraftComment(
+            changeNotesFactory.create(rsrc.getProject(), rsrc.getChange().getId()),
+            rsrc.getUser(),
+            TimeUtil.now(),
+            in,
+            rsrc.getChange(),
+            rsrc.getPatchSet(),
+            commentsUtil);
+
+    CommentValidationContext ctx =
+        CommentValidationContext.create(
+            rsrc.getChange().getChangeId(),
+            rsrc.getChange().getProject().get(),
+            rsrc.getChange().getDest().branch());
+
+    ImmutableList<CommentValidationFailure> validationFailures =
+        PublishCommentUtil.findInvalidComments(
+            ctx,
+            commentValidators,
+            ImmutableList.of(
+                CommentForValidation.create(
+                    CommentSource.HUMAN,
+                    comment.lineNbr > 0 ? CommentType.INLINE_COMMENT : CommentType.FILE_COMMENT,
+                    comment.message,
+                    comment.message.length())));
+
+    if (!validationFailures.isEmpty()) {
+      throw new BadRequestException(
+          String.format(
+              "Found an invalid draft comment after validation: %s",
+              validationFailures.stream()
+                  .map(CommentValidationFailure::getMessage)
+                  .collect(Collectors.toList())),
+          new CommentsRejectedException(validationFailures));
+    }
+  }
+
+  private static HumanComment createDraftComment(
+      ChangeNotes notes,
+      CurrentUser user,
+      Instant when,
+      DraftInput draftInput,
+      Change change,
+      PatchSet ps,
+      CommentsUtil commentsUtil) {
+    String parentUuid = Url.decode(draftInput.inReplyTo);
+
+    HumanComment comment =
+        commentsUtil.newHumanComment(
+            notes,
+            user,
+            when,
+            draftInput.path,
+            ps.id(),
+            draftInput.side(),
+            draftInput.message.trim(),
+            draftInput.unresolved,
+            parentUuid);
+    comment.setLineNbrAndRange(draftInput.line, draftInput.range);
+    comment.tag = draftInput.tag;
+
+    commentsUtil.setCommentCommitId(comment, change, ps);
+    return comment;
+  }
+
   private class Op implements BatchUpdateOp {
     private final PatchSet.Id psId;
     private final DraftInput in;
@@ -113,23 +208,10 @@
       if (ps == null) {
         throw new ResourceNotFoundException("patch set not found: " + psId);
       }
-      String parentUuid = Url.decode(in.inReplyTo);
 
       comment =
-          commentsUtil.newHumanComment(
-              ctx.getNotes(),
-              ctx.getUser(),
-              ctx.getWhen(),
-              in.path,
-              ps.id(),
-              in.side(),
-              in.message.trim(),
-              in.unresolved,
-              parentUuid);
-      comment.setLineNbrAndRange(in.line, in.range);
-      comment.tag = in.tag;
-
-      commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
+          createDraftComment(
+              ctx.getNotes(), ctx.getUser(), ctx.getWhen(), in, ctx.getChange(), ps, commentsUtil);
 
       commentsUtil.putHumanComments(
           ctx.getUpdate(psId), HumanComment.Status.DRAFT, Collections.singleton(comment));
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index 2059485..5ad4259 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -19,6 +19,7 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
@@ -73,6 +74,7 @@
 import java.time.Instant;
 import java.time.ZoneId;
 import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -185,6 +187,14 @@
           in.author == null
               ? me.newCommitterIdent(now, serverZoneId)
               : new PersonIdent(in.author.name, in.author.email, now, serverZoneId);
+      RevCommit commit = rw.parseCommit(ps.commitId());
+      PersonIdent committer =
+          Optional.ofNullable(commit.getCommitterIdent())
+              .map(
+                  ident ->
+                      me.newCommitterIdent(ident.getEmailAddress(), now, serverZoneId)
+                          .orElseGet(() -> me.newCommitterIdent(now, serverZoneId)))
+              .orElseGet(() -> me.newCommitterIdent(now, serverZoneId));
       CodeReviewCommit newCommit =
           createMergeCommit(
               in,
@@ -196,6 +206,7 @@
               currentPsCommit,
               sourceCommit,
               author,
+              committer,
               ObjectId.fromString(change.getKey().get().substring(1)));
       oi.flush();
 
@@ -210,6 +221,16 @@
               .setMessage(messageForChange(nextPsId, newCommit))
               .setWorkInProgress(!newCommit.getFilesWithGitConflicts().isEmpty())
               .setCheckAddPatchSetPermission(false);
+
+          if (in.validationOptions != null) {
+            ImmutableListMultimap.Builder<String, String> validationOptions =
+                ImmutableListMultimap.builder();
+            in.validationOptions
+                .entrySet()
+                .forEach(e -> validationOptions.put(e.getKey(), e.getValue()));
+            psInserter.setValidationOptions(validationOptions.build());
+          }
+
           if (groups != null) {
             psInserter.setGroups(groups);
           }
@@ -253,6 +274,7 @@
       RevCommit currentPsCommit,
       RevCommit sourceCommit,
       PersonIdent author,
+      PersonIdent committer,
       ObjectId changeId)
       throws ResourceNotFoundException,
           MergeIdenticalTreeException,
@@ -297,6 +319,7 @@
         mergeStrategy,
         in.merge.allowConflicts,
         author,
+        committer,
         commitMsg,
         rw);
   }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
index f55e9c7..4e17de3 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.DraftCommentResource;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -43,13 +44,19 @@
 public class DeleteDraftComment implements RestModifyView<DraftCommentResource, Input> {
   private final BatchUpdate.Factory updateFactory;
   private final CommentsUtil commentsUtil;
+  private final DraftCommentsReader draftCommentsReader;
+
   private final PatchSetUtil psUtil;
 
   @Inject
   DeleteDraftComment(
-      BatchUpdate.Factory updateFactory, CommentsUtil commentsUtil, PatchSetUtil psUtil) {
+      BatchUpdate.Factory updateFactory,
+      CommentsUtil commentsUtil,
+      DraftCommentsReader draftCommentsReader,
+      PatchSetUtil psUtil) {
     this.updateFactory = updateFactory;
     this.commentsUtil = commentsUtil;
+    this.draftCommentsReader = draftCommentsReader;
     this.psUtil = psUtil;
   }
 
@@ -77,7 +84,7 @@
     @Override
     public boolean updateChange(ChangeContext ctx) throws ResourceNotFoundException {
       Optional<HumanComment> maybeComment =
-          commentsUtil.getDraft(ctx.getNotes(), ctx.getIdentifiedUser(), key);
+          draftCommentsReader.getDraftComment(ctx.getNotes(), ctx.getIdentifiedUser(), key);
       if (!maybeComment.isPresent()) {
         return false; // Nothing to do.
       }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
index 3ac4d22..d2907c8 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.mail.EmailFactories.VOTE_DELETED;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Objects.requireNonNull;
 
@@ -35,9 +36,10 @@
 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.EmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ReplyToChangeSender;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.permissions.LabelRemovalPermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.DeleteVoteControl;
@@ -76,7 +78,7 @@
   private final PatchSetUtil psUtil;
   private final ChangeMessagesUtil cmUtil;
   private final VoteDeleted voteDeleted;
-  private final DeleteVoteSender.Factory deleteVoteSenderFactory;
+  private final EmailFactories emailFactories;
 
   private final DeleteVoteControl deleteVoteControl;
   private final RemoveReviewerControl removeReviewerControl;
@@ -99,7 +101,7 @@
       PatchSetUtil psUtil,
       ChangeMessagesUtil cmUtil,
       VoteDeleted voteDeleted,
-      DeleteVoteSender.Factory deleteVoteSenderFactory,
+      EmailFactories emailFactories,
       DeleteVoteControl deleteVoteControl,
       MessageIdGenerator messageIdGenerator,
       RemoveReviewerControl removeReviewerControl,
@@ -113,7 +115,7 @@
     this.psUtil = psUtil;
     this.cmUtil = cmUtil;
     this.voteDeleted = voteDeleted;
-    this.deleteVoteSenderFactory = deleteVoteSenderFactory;
+    this.emailFactories = emailFactories;
     this.deleteVoteControl = deleteVoteControl;
     this.removeReviewerControl = removeReviewerControl;
     this.messageIdGenerator = messageIdGenerator;
@@ -187,17 +189,19 @@
 
     CurrentUser user = ctx.getUser();
     try {
+      ChangeEmail changeEmail =
+          emailFactories.createChangeEmail(
+              ctx.getProject(), change.getId(), emailFactories.createDeleteVoteChangeEmail());
+      changeEmail.setChangeMessage(mailMessage, ctx.getWhen());
+      OutgoingEmail outgoingEmail = emailFactories.createOutgoingEmail(VOTE_DELETED, changeEmail);
       NotifyResolver.Result notify = ctx.getNotify(change.getId());
-      ReplyToChangeSender emailSender =
-          deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
       if (user.isIdentifiedUser()) {
-        emailSender.setFrom(user.getAccountId());
+        outgoingEmail.setFrom(user.getAccountId());
       }
-      emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-      emailSender.setNotify(notify);
-      emailSender.setMessageId(
+      outgoingEmail.setNotify(notify);
+      outgoingEmail.setMessageId(
           messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
-      emailSender.send();
+      outgoingEmail.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
     }
diff --git a/java/com/google/gerrit/server/restapi/change/DownloadContent.java b/java/com/google/gerrit/server/restapi/change/DownloadContent.java
index 74c0acc..ae801b4 100644
--- a/java/com/google/gerrit/server/restapi/change/DownloadContent.java
+++ b/java/com/google/gerrit/server/restapi/change/DownloadContent.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -44,7 +45,7 @@
 
   @Override
   public Response<BinaryResult> apply(FileResource rsrc)
-      throws ResourceNotFoundException, IOException, NoSuchChangeException {
+      throws BadRequestException, ResourceNotFoundException, IOException, NoSuchChangeException {
     String path = rsrc.getPatchKey().fileName();
     RevisionResource rev = rsrc.getRevision();
     return Response.ok(
diff --git a/java/com/google/gerrit/server/restapi/change/DraftComments.java b/java/com/google/gerrit/server/restapi/change/DraftComments.java
index a4c9400..7c4f596 100644
--- a/java/com/google/gerrit/server/restapi/change/DraftComments.java
+++ b/java/com/google/gerrit/server/restapi/change/DraftComments.java
@@ -21,8 +21,8 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.change.DraftCommentResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.inject.Inject;
@@ -34,18 +34,18 @@
   private final DynamicMap<RestView<DraftCommentResource>> views;
   private final Provider<CurrentUser> user;
   private final ListRevisionDrafts list;
-  private final CommentsUtil commentsUtil;
+  private final DraftCommentsReader draftCommentsReader;
 
   @Inject
   DraftComments(
       DynamicMap<RestView<DraftCommentResource>> views,
       Provider<CurrentUser> user,
       ListRevisionDrafts list,
-      CommentsUtil commentsUtil) {
+      DraftCommentsReader draftCommentsReader) {
     this.views = views;
     this.user = user;
     this.list = list;
-    this.commentsUtil = commentsUtil;
+    this.draftCommentsReader = draftCommentsReader;
   }
 
   @Override
@@ -65,8 +65,8 @@
     checkIdentifiedUser();
     String uuid = id.get();
     for (HumanComment c :
-        commentsUtil.draftByPatchSetAuthor(
-            rev.getPatchSet().id(), rev.getAccountId(), rev.getNotes())) {
+        draftCommentsReader.getDraftsByPatchSetAndDraftAuthor(
+            rev.getNotes(), rev.getPatchSet().id(), rev.getAccountId())) {
       if (uuid.equals(c.key.uuid)) {
         return new DraftCommentResource(rev, c);
       }
diff --git a/java/com/google/gerrit/server/restapi/change/GetCommit.java b/java/com/google/gerrit/server/restapi/change/GetCommit.java
index 5193501..c02db06 100644
--- a/java/com/google/gerrit/server/restapi/change/GetCommit.java
+++ b/java/com/google/gerrit/server/restapi/change/GetCommit.java
@@ -65,8 +65,11 @@
                   commit,
                   addLinks,
                   /* fillCommit= */ true,
-                  rsrc.getChange().getDest().branch(),
-                  rsrc.getChange().getKey().get());
+                  rsrc.getPatchSet().branch().isPresent()
+                      ? rsrc.getPatchSet().branch().get()
+                      : rsrc.getChange().getDest().branch(),
+                  rsrc.getChange().getKey().get(),
+                  rsrc.getChange().getId().get());
       Response<CommitInfo> r = Response.ok(info);
       if (rsrc.isCacheable()) {
         r.caching(CacheControl.PRIVATE(7, DAYS));
diff --git a/java/com/google/gerrit/server/restapi/change/GetCustomKeyedValues.java b/java/com/google/gerrit/server/restapi/change/GetCustomKeyedValues.java
new file mode 100644
index 0000000..47765ab
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetCustomKeyedValues.java
@@ -0,0 +1,39 @@
+// 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.restapi.change;
+
+import com.google.common.collect.ImmutableMap;
+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.RestReadView;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class GetCustomKeyedValues implements RestReadView<ChangeResource> {
+  @Override
+  public Response<ImmutableMap<String, String>> apply(ChangeResource req)
+      throws AuthException, IOException, BadRequestException {
+    ChangeNotes notes = req.getNotes().load();
+    ImmutableMap<String, String> customKeyedValues = notes.getCustomKeyedValues();
+    if (customKeyedValues == null) {
+      customKeyedValues = ImmutableMap.of();
+    }
+    return Response.ok(customKeyedValues);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetMergeList.java b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
index 551b50f..3d8f4e3 100644
--- a/java/com/google/gerrit/server/restapi/change/GetMergeList.java
+++ b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
@@ -90,7 +90,8 @@
                 addLinks,
                 /* fillCommit= */ true,
                 rsrc.getChange().getDest().branch(),
-                rsrc.getChange().getKey().get()));
+                rsrc.getChange().getKey().get(),
+                rsrc.getChange().getId().get()));
       }
       return createResponse(rsrc, result);
     }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
index 89ee399..9faa9b5 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
@@ -20,7 +20,7 @@
 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.CommentsUtil;
+import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -34,7 +34,7 @@
 public class ListChangeDrafts implements RestReadView<ChangeResource> {
   private final ChangeData.Factory changeDataFactory;
   private final Provider<CommentJson> commentJson;
-  private final CommentsUtil commentsUtil;
+  private final DraftCommentsReader draftCommentsReader;
 
   private boolean includeContext;
   private int contextPadding;
@@ -64,15 +64,16 @@
   ListChangeDrafts(
       ChangeData.Factory changeDataFactory,
       Provider<CommentJson> commentJson,
-      CommentsUtil commentsUtil) {
+      DraftCommentsReader draftCommentsReader) {
     this.changeDataFactory = changeDataFactory;
     this.commentJson = commentJson;
-    this.commentsUtil = commentsUtil;
+    this.draftCommentsReader = draftCommentsReader;
   }
 
   private Iterable<HumanComment> listComments(ChangeResource rsrc) {
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
-    return commentsUtil.draftByChangeAuthor(cd.notes(), rsrc.getUser().getAccountId());
+    return draftCommentsReader.getDraftsByChangeAndDraftAuthor(
+        cd.notes(), rsrc.getUser().getAccountId());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java b/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java
index e92fe5c..f3d5f37 100644
--- a/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
@@ -34,15 +34,17 @@
 @Singleton
 public class ListPortedDrafts implements RestReadView<RevisionResource> {
 
-  private final CommentsUtil commentsUtil;
+  private final DraftCommentsReader draftCommentsReader;
   private final CommentPorter commentPorter;
   private final Provider<CommentJson> commentJson;
 
   @Inject
   public ListPortedDrafts(
-      Provider<CommentJson> commentJson, CommentsUtil commentsUtil, CommentPorter commentPorter) {
+      Provider<CommentJson> commentJson,
+      DraftCommentsReader draftCommentsReader,
+      CommentPorter commentPorter) {
     this.commentJson = commentJson;
-    this.commentsUtil = commentsUtil;
+    this.draftCommentsReader = draftCommentsReader;
     this.commentPorter = commentPorter;
   }
 
@@ -55,7 +57,7 @@
     PatchSet targetPatchset = revisionResource.getPatchSet();
 
     List<HumanComment> draftComments =
-        commentsUtil.draftByChangeAuthor(
+        draftCommentsReader.getDraftsByChangeAndDraftAuthor(
             revisionResource.getNotes(), revisionResource.getAccountId());
     ImmutableList<HumanComment> portedDraftComments =
         commentPorter.portComments(
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java b/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java
index 88309ed..d969a9a 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionComments.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
@@ -24,9 +25,15 @@
 
 @Singleton
 public class ListRevisionComments extends ListRevisionDrafts {
+  private final CommentsUtil commentsUtil;
+
   @Inject
-  ListRevisionComments(Provider<CommentJson> commentJson, CommentsUtil commentsUtil) {
-    super(commentJson, commentsUtil);
+  ListRevisionComments(
+      Provider<CommentJson> commentJson,
+      CommentsUtil commentsUtil,
+      DraftCommentsReader draftCommentsReader) {
+    super(commentJson, draftCommentsReader);
+    this.commentsUtil = commentsUtil;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java b/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
index a5fbd92..de05a16 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionDrafts.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
@@ -31,17 +31,17 @@
 @Singleton
 public class ListRevisionDrafts implements RestReadView<RevisionResource> {
   protected final Provider<CommentJson> commentJson;
-  protected final CommentsUtil commentsUtil;
+  private final DraftCommentsReader draftCommentsReader;
 
   @Inject
-  ListRevisionDrafts(Provider<CommentJson> commentJson, CommentsUtil commentsUtil) {
+  ListRevisionDrafts(Provider<CommentJson> commentJson, DraftCommentsReader draftCommentsReader) {
     this.commentJson = commentJson;
-    this.commentsUtil = commentsUtil;
+    this.draftCommentsReader = draftCommentsReader;
   }
 
   protected Iterable<HumanComment> listComments(RevisionResource rsrc) {
-    return commentsUtil.draftByPatchSetAuthor(
-        rsrc.getPatchSet().id(), rsrc.getAccountId(), rsrc.getNotes());
+    return draftCommentsReader.getDraftsByPatchSetAndDraftAuthor(
+        rsrc.getNotes(), rsrc.getPatchSet().id(), rsrc.getAccountId());
   }
 
   protected boolean includeAuthorInfo() {
diff --git a/java/com/google/gerrit/server/restapi/change/PostCustomKeyedValues.java b/java/com/google/gerrit/server/restapi/change/PostCustomKeyedValues.java
new file mode 100644
index 0000000..d97107a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PostCustomKeyedValues.java
@@ -0,0 +1,71 @@
+// 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.restapi.change;
+
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.api.changes.CustomKeyedValuesInput;
+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.change.ChangeResource;
+import com.google.gerrit.server.change.SetCustomKeyedValuesOp;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class PostCustomKeyedValues
+    implements RestModifyView<ChangeResource, CustomKeyedValuesInput>, UiAction<ChangeResource> {
+  private final BatchUpdate.Factory updateFactory;
+  private final SetCustomKeyedValuesOp.Factory customKeyedValuesFactory;
+
+  @Inject
+  PostCustomKeyedValues(
+      BatchUpdate.Factory updateFactory, SetCustomKeyedValuesOp.Factory customKeyedValuesFactory) {
+    this.updateFactory = updateFactory;
+    this.customKeyedValuesFactory = customKeyedValuesFactory;
+  }
+
+  @Override
+  public Response<ImmutableMap<String, String>> apply(
+      ChangeResource req, CustomKeyedValuesInput input)
+      throws RestApiException, UpdateException, PermissionBackendException {
+    req.permissions().check(ChangePermission.EDIT_CUSTOM_KEYED_VALUES);
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate bu =
+          updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.now())) {
+        SetCustomKeyedValuesOp op = customKeyedValuesFactory.create(input);
+        bu.addOp(req.getId(), op);
+        bu.execute();
+        return Response.ok(op.getUpdatedCustomKeyedValues());
+      }
+    }
+  }
+
+  @Override
+  public UiAction.Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Edit custom keyed values")
+        .setVisible(rsrc.permissions().testCond(ChangePermission.EDIT_CUSTOM_KEYED_VALUES));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index c33962b..b68d9e1 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -72,12 +72,14 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.IdentifiedUser;
 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.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ModifyReviewersEmail;
 import com.google.gerrit.server.change.NotifyResolver;
@@ -163,6 +165,8 @@
   private final AccountCache accountCache;
   private final ApprovalsUtil approvalsUtil;
   private final CommentsUtil commentsUtil;
+  private final DraftCommentsReader draftCommentsReader;
+
   private final PatchListCache patchListCache;
   private final AccountResolver accountResolver;
   private final ReviewerModifier reviewerModifier;
@@ -176,6 +180,7 @@
   private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
   private final ReviewerAdded reviewerAdded;
   private final boolean strictLabels;
+  private final ChangeJson.Factory changeJsonFactory;
 
   @Inject
   PostReview(
@@ -186,6 +191,7 @@
       AccountCache accountCache,
       ApprovalsUtil approvalsUtil,
       CommentsUtil commentsUtil,
+      DraftCommentsReader draftCommentsReader,
       PatchListCache patchListCache,
       AccountResolver accountResolver,
       ReviewerModifier reviewerModifier,
@@ -197,13 +203,15 @@
       ProjectCache projectCache,
       PermissionBackend permissionBackend,
       ReplyAttentionSetUpdates replyAttentionSetUpdates,
-      ReviewerAdded reviewerAdded) {
+      ReviewerAdded reviewerAdded,
+      ChangeJson.Factory changeJsonFactory) {
     this.updateFactory = updateFactory;
     this.postReviewOpFactory = postReviewOpFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.changeDataFactory = changeDataFactory;
     this.accountCache = accountCache;
     this.commentsUtil = commentsUtil;
+    this.draftCommentsReader = draftCommentsReader;
     this.patchListCache = patchListCache;
     this.approvalsUtil = approvalsUtil;
     this.accountResolver = accountResolver;
@@ -217,6 +225,7 @@
     this.replyAttentionSetUpdates = replyAttentionSetUpdates;
     this.reviewerAdded = reviewerAdded;
     this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
+    this.changeJsonFactory = changeJsonFactory;
   }
 
   @Override
@@ -412,6 +421,10 @@
     batchEmailReviewers(revision.getUser(), revision.getChange(), reviewerResults, notify);
     batchReviewerEvents(revision.getUser(), cd, revision.getPatchSet(), reviewerResults, ts);
 
+    if (input.responseFormatOptions != null) {
+      output.changeInfo = changeJsonFactory.create(input.responseFormatOptions).format(cd);
+    }
+
     return Response.ok(output);
   }
 
@@ -697,8 +710,8 @@
       RevisionResource resource, List<String> draftIds, DraftHandling draftHandling)
       throws BadRequestException {
     Map<String, HumanComment> draftsByUuid =
-        commentsUtil
-            .draftByChangeAuthor(resource.getNotes(), resource.getUser().getAccountId())
+        draftCommentsReader
+            .getDraftsByChangeAndDraftAuthor(resource.getNotes(), resource.getUser().getAccountId())
             .stream()
             .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
     List<String> nonExistingDraftIds =
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
index 5cacf75..c7b52b1 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
@@ -59,6 +59,7 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.PublishCommentUtil;
@@ -96,7 +97,7 @@
 import org.eclipse.jgit.lib.Config;
 
 public class PostReviewOp implements BatchUpdateOp {
-  interface Factory {
+  public interface Factory {
     PostReviewOp create(
         ProjectState projectState, PatchSet.Id psId, ReviewInput in, Account.Id reviewerId);
   }
@@ -183,6 +184,7 @@
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
   private final CommentsUtil commentsUtil;
+  private final DraftCommentsReader draftCommentsReader;
   private final PublishCommentUtil publishCommentUtil;
   private final PatchSetUtil psUtil;
   private final EmailReviewComments.Factory email;
@@ -214,6 +216,7 @@
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
       CommentsUtil commentsUtil,
+      DraftCommentsReader draftCommentsReader,
       PublishCommentUtil publishCommentUtil,
       PatchSetUtil psUtil,
       EmailReviewComments.Factory email,
@@ -230,6 +233,7 @@
     this.psUtil = psUtil;
     this.cmUtil = cmUtil;
     this.commentsUtil = commentsUtil;
+    this.draftCommentsReader = draftCommentsReader;
     this.email = email;
     this.commentAdded = commentAdded;
     this.commentValidators = commentValidators;
@@ -404,7 +408,7 @@
                   inputComment.unresolved,
                   parent);
         } else {
-          // In ChangeUpdate#putComment() the draft with the same ID will be deleted.
+          // In ChangeUpdate#putDraftComment() the draft with the same ID will be deleted.
           comment.writtenOn = Timestamp.from(ctx.getWhen());
           comment.side = inputComment.side();
           comment.message = inputComment.message;
@@ -554,12 +558,16 @@
   }
 
   private Map<String, HumanComment> changeDrafts(ChangeContext ctx) {
-    return commentsUtil.draftByChangeAuthor(ctx.getNotes(), user.getAccountId()).stream()
+    return draftCommentsReader
+        .getDraftsByChangeAndDraftAuthor(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()
+    return draftCommentsReader
+        .getDraftsByPatchSetAndDraftAuthor(ctx.getNotes(), psId, user.getAccountId())
+        .stream()
         .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
   }
 
@@ -651,8 +659,8 @@
           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.
+        // Only allow voting again if 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)
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewers.java b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
index 95c56ef..5d51ccd 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.change;
 
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -74,7 +75,7 @@
     ReviewerModification modification =
         reviewerModifier.prepare(rsrc.getNotes(), rsrc.getUser(), input, true);
     if (modification.op == null) {
-      return Response.ok(modification.result);
+      return Response.withStatusCode(SC_BAD_REQUEST, modification.result);
     }
     try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
       try (BatchUpdate bu =
diff --git a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
index 681e1b1..345d915 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
@@ -28,11 +28,15 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.DraftCommentResource;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -51,21 +55,30 @@
   private final BatchUpdate.Factory updateFactory;
   private final DeleteDraftComment delete;
   private final CommentsUtil commentsUtil;
+  private final DraftCommentsReader draftCommentsReader;
   private final PatchSetUtil psUtil;
   private final Provider<CommentJson> commentJson;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final PluginSetContext<CommentValidator> commentValidators;
 
   @Inject
   PutDraftComment(
       BatchUpdate.Factory updateFactory,
       DeleteDraftComment delete,
       CommentsUtil commentsUtil,
+      DraftCommentsReader draftCommentsReader,
       PatchSetUtil psUtil,
-      Provider<CommentJson> commentJson) {
+      Provider<CommentJson> commentJson,
+      ChangeNotes.Factory changeNotesFactory,
+      PluginSetContext<CommentValidator> commentValidators) {
     this.updateFactory = updateFactory;
     this.delete = delete;
     this.commentsUtil = commentsUtil;
+    this.draftCommentsReader = draftCommentsReader;
     this.psUtil = psUtil;
     this.commentJson = commentJson;
+    this.changeNotesFactory = changeNotesFactory;
+    this.commentValidators = commentValidators;
   }
 
   @Override
@@ -88,6 +101,8 @@
       throw new BadRequestException(
           String.format("Invalid inReplyTo, comment %s not found", in.inReplyTo));
     }
+    CreateDraftComment.validateDraftComment(
+        rsrc.getRevisionResource(), in, changeNotesFactory, commentValidators, commentsUtil);
     try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
       try (BatchUpdate bu =
           updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
@@ -114,7 +129,7 @@
     @Override
     public boolean updateChange(ChangeContext ctx) throws ResourceNotFoundException {
       Optional<HumanComment> maybeComment =
-          commentsUtil.getDraft(ctx.getNotes(), ctx.getIdentifiedUser(), key);
+          draftCommentsReader.getDraftComment(ctx.getNotes(), ctx.getIdentifiedUser(), key);
       if (!maybeComment.isPresent()) {
         // Disappeared out from under us. Can't easily fall back to insert,
         // because the input might be missing required fields. Just give up.
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index db8851e..f79e187 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.CommitMessageInput;
-import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -31,11 +30,11 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
-import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -53,6 +52,7 @@
 import java.io.IOException;
 import java.time.Instant;
 import java.time.ZoneId;
+import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
@@ -74,7 +74,7 @@
   private final PatchSetUtil psUtil;
   private final NotifyResolver notifyResolver;
   private final ProjectCache projectCache;
-  private final DynamicItem<UrlFormatter> urlFormatter;
+  private final ChangeUtil changeUtil;
 
   @Inject
   PutMessage(
@@ -87,7 +87,7 @@
       PatchSetUtil psUtil,
       NotifyResolver notifyResolver,
       ProjectCache projectCache,
-      DynamicItem<UrlFormatter> urlFormatter) {
+      ChangeUtil changeUtil) {
     this.updateFactory = updateFactory;
     this.repositoryManager = repositoryManager;
     this.userProvider = userProvider;
@@ -97,7 +97,7 @@
     this.psUtil = psUtil;
     this.notifyResolver = notifyResolver;
     this.projectCache = projectCache;
-    this.urlFormatter = urlFormatter;
+    this.changeUtil = changeUtil;
   }
 
   @Override
@@ -118,14 +118,13 @@
     String sanitizedCommitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(input.message);
 
     ensureCanEditCommitMessage(resource.getNotes());
-    ChangeUtil.ensureChangeIdIsCorrect(
+    changeUtil.ensureChangeIdIsCorrect(
         projectCache
             .get(resource.getProject())
             .orElseThrow(illegalState(resource.getProject()))
             .is(BooleanProjectConfig.REQUIRE_CHANGE_ID),
         resource.getChange().getKey().get(),
-        sanitizedCommitMessage,
-        urlFormatter.get());
+        sanitizedCommitMessage);
 
     try (Repository repository = repositoryManager.openRepository(resource.getProject());
         RevWalk revWalk = new RevWalk(repository);
@@ -181,8 +180,15 @@
     builder.setTreeId(basePatchSetCommit.getTree());
     builder.setParentIds(basePatchSetCommit.getParents());
     builder.setAuthor(basePatchSetCommit.getAuthorIdent());
-    builder.setCommitter(
-        userProvider.get().asIdentifiedUser().newCommitterIdent(timestamp, zoneId));
+    IdentifiedUser user = userProvider.get().asIdentifiedUser();
+    PersonIdent committer =
+        Optional.ofNullable(basePatchSetCommit.getCommitterIdent())
+            .map(
+                ident ->
+                    user.newCommitterIdent(ident.getEmailAddress(), timestamp, zoneId)
+                        .orElseGet(() -> user.newCommitterIdent(timestamp, zoneId)))
+            .orElseGet(() -> user.newCommitterIdent(timestamp, zoneId));
+    builder.setCommitter(committer);
     builder.setMessage(commitMessage);
     ObjectId newCommitId = objectInserter.insert(builder);
     objectInserter.flush();
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index 6ce4b39..4d279b0 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -60,6 +60,7 @@
   private Integer start;
   private Boolean noLimit;
   private Boolean skipVisibility;
+  private Boolean allowIncompleteResults;
 
   @Option(
       name = "--query",
@@ -112,6 +113,11 @@
     skipVisibility = on;
   }
 
+  @Option(name = "--allow-incomplete-results", usage = "Return partial results")
+  public void setAllowIncompleteResults(boolean allowIncompleteResults) {
+    this.allowIncompleteResults = allowIncompleteResults;
+  }
+
   @Override
   public void setDynamicBean(String plugin, DynamicOptions.DynamicBean dynamicBean) {
     dynamicBeans.put(plugin, dynamicBean);
@@ -181,6 +187,9 @@
     if (skipVisibility != null) {
       queryProcessor.enforceVisibility(!skipVisibility);
     }
+    if (allowIncompleteResults != null) {
+      queryProcessor.setAllowIncompleteResults(allowIncompleteResults);
+    }
     dynamicBeans.forEach((p, b) -> queryProcessor.setDynamicBean(p, b));
 
     if (queries == null || queries.isEmpty()) {
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 167f784..0daef20 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -29,6 +29,7 @@
 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.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
@@ -68,6 +69,7 @@
   private final ProjectCache projectCache;
   private final PatchSetUtil patchSetUtil;
   private final RebaseMetrics rebaseMetrics;
+  private final IdentifiedUser.GenericFactory userFactory;
 
   @Inject
   public Rebase(
@@ -78,7 +80,8 @@
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
       PatchSetUtil patchSetUtil,
-      RebaseMetrics rebaseMetrics) {
+      RebaseMetrics rebaseMetrics,
+      IdentifiedUser.GenericFactory userFactory) {
     this.updateFactory = updateFactory;
     this.repoManager = repoManager;
     this.rebaseUtil = rebaseUtil;
@@ -87,16 +90,20 @@
     this.projectCache = projectCache;
     this.patchSetUtil = patchSetUtil;
     this.rebaseMetrics = rebaseMetrics;
+    this.userFactory = userFactory;
   }
 
   @Override
   public Response<ChangeInfo> apply(RevisionResource rsrc, RebaseInput input)
       throws UpdateException, RestApiException, IOException, PermissionBackendException {
-
+    IdentifiedUser rebaseAsUser;
     if (input.onBehalfOfUploader && !rsrc.getPatchSet().uploader().equals(rsrc.getAccountId())) {
+      rebaseAsUser =
+          userFactory.runAs(/* remotePeer= */ null, rsrc.getPatchSet().uploader(), rsrc.getUser());
       rsrc.permissions().check(ChangePermission.REBASE_ON_BEHALF_OF_UPLOADER);
-      rsrc = rebaseUtil.onBehalfOf(rsrc, input);
+      rebaseUtil.checkCanRebaseOnBehalfOf(rsrc, input);
     } else {
+      rebaseAsUser = rsrc.getUser().asIdentifiedUser();
       input.onBehalfOfUploader = false;
       rsrc.permissions().check(ChangePermission.REBASE);
     }
@@ -113,14 +120,16 @@
           ObjectReader reader = oi.newReader();
           RevWalk rw = CodeReviewCommit.newRevWalk(reader);
           BatchUpdate bu =
-              updateFactory.create(change.getProject(), rsrc.getUser(), TimeUtil.now())) {
+              updateFactory.create(change.getProject(), rebaseAsUser, TimeUtil.now())) {
         rebaseUtil.verifyRebasePreconditions(rw, rsrc.getNotes(), rsrc.getPatchSet());
 
         RebaseChangeOp rebaseOp =
             rebaseUtil.getRebaseOp(
+                rw,
                 rsrc,
                 input,
-                rebaseUtil.parseOrFindBaseRevision(repo, rw, permissionBackend, rsrc, input, true));
+                rebaseUtil.parseOrFindBaseRevision(repo, rw, permissionBackend, rsrc, input, true),
+                rebaseAsUser);
 
         // TODO(dborowitz): Why no notification? This seems wrong; dig up blame.
         bu.setNotify(NotifyResolver.Result.none());
diff --git a/java/com/google/gerrit/server/restapi/change/RebaseChain.java b/java/com/google/gerrit/server/restapi/change/RebaseChain.java
index 343fb72..76305de 100644
--- a/java/com/google/gerrit/server/restapi/change/RebaseChain.java
+++ b/java/com/google/gerrit/server/restapi/change/RebaseChain.java
@@ -36,6 +36,7 @@
 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.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
@@ -89,6 +90,7 @@
   private final PatchSetUtil patchSetUtil;
   private final ChangeJson.Factory json;
   private final RebaseMetrics rebaseMetrics;
+  private final IdentifiedUser.GenericFactory userFactory;
 
   @Inject
   RebaseChain(
@@ -103,7 +105,8 @@
       ProjectCache projectCache,
       PatchSetUtil patchSetUtil,
       ChangeJson.Factory json,
-      RebaseMetrics rebaseMetrics) {
+      RebaseMetrics rebaseMetrics,
+      IdentifiedUser.GenericFactory userFactory) {
     this.repoManager = repoManager;
     this.getRelatedChangesUtil = getRelatedChangesUtil;
     this.changeDataFactory = changeDataFactory;
@@ -116,11 +119,18 @@
     this.patchSetUtil = patchSetUtil;
     this.json = json;
     this.rebaseMetrics = rebaseMetrics;
+    this.userFactory = userFactory;
   }
 
   @Override
   public Response<RebaseChainInfo> apply(ChangeResource tipRsrc, RebaseInput input)
       throws IOException, PermissionBackendException, RestApiException, UpdateException {
+    IdentifiedUser rebaseAsUser;
+    if (input.committerEmail != null) {
+      // TODO: committer_email can be supported if all changes in the chain
+      //  belong to the same uploader. It can be attempted in future as needed.
+      throw new BadRequestException("committer_email is not supported when rebasing a chain");
+    }
     if (input.onBehalfOfUploader) {
       tipRsrc.permissions().check(ChangePermission.REBASE_ON_BEHALF_OF_UPLOADER);
       if (input.allowConflicts) {
@@ -160,10 +170,14 @@
               new RevisionResource(changeResourceFactory.create(changeData, user), ps);
           if (input.onBehalfOfUploader
               && !revRsrc.getPatchSet().uploader().equals(revRsrc.getAccountId())) {
-            revRsrc = rebaseUtil.onBehalfOf(revRsrc, input);
+            rebaseAsUser =
+                userFactory.runAs(
+                    /* remotePeer= */ null, revRsrc.getPatchSet().uploader(), revRsrc.getUser());
+            rebaseUtil.checkCanRebaseOnBehalfOf(revRsrc, input);
             revRsrc.permissions().check(ChangePermission.REBASE_ON_BEHALF_OF_UPLOADER);
             anyRebaseOnBehalfOfUploader = true;
           } else {
+            rebaseAsUser = revRsrc.getUser().asIdentifiedUser();
             revRsrc.permissions().check(ChangePermission.REBASE);
           }
           rebaseUtil.verifyRebasePreconditions(rw, changeData.notes(), ps);
@@ -177,7 +191,7 @@
             if (currentBase(rw, ps).equals(desiredBase)) {
               isUpToDate = true;
             } else {
-              rebaseOp = rebaseUtil.getRebaseOp(revRsrc, input, desiredBase);
+              rebaseOp = rebaseUtil.getRebaseOp(rw, revRsrc, input, desiredBase, rebaseAsUser);
             }
           } else {
             if (ancestorsAreUpToDate) {
@@ -187,7 +201,8 @@
               isUpToDate = currentBase(rw, ps).equals(latestCommittedBase);
             }
             if (!isUpToDate) {
-              rebaseOp = rebaseUtil.getRebaseOp(revRsrc, input, chain.get(i - 1).id());
+              rebaseOp =
+                  rebaseUtil.getRebaseOp(rw, revRsrc, input, chain.get(i - 1).id(), rebaseAsUser);
             }
           }
 
@@ -196,7 +211,7 @@
             continue;
           }
           ancestorsAreUpToDate = false;
-          bu.addOp(revRsrc.getChange().getId(), revRsrc.getUser(), rebaseOp);
+          bu.addOp(revRsrc.getChange().getId(), rebaseAsUser, rebaseOp);
           rebaseOps.put(revRsrc.getChange().getId(), rebaseOp);
         }
 
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index eab251f..ce9b176 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.approval.ApprovalsUtil;
@@ -71,6 +72,7 @@
   private final AccountResolver accountResolver;
   private final ServiceUserClassifier serviceUserClassifier;
   private final CommentsUtil commentsUtil;
+  private final DraftCommentsReader draftCommentsReader;
 
   @Inject
   ReplyAttentionSetUpdates(
@@ -80,7 +82,8 @@
       ApprovalsUtil approvalsUtil,
       AccountResolver accountResolver,
       ServiceUserClassifier serviceUserClassifier,
-      CommentsUtil commentsUtil) {
+      CommentsUtil commentsUtil,
+      DraftCommentsReader draftCommentsReader) {
     this.permissionBackend = permissionBackend;
     this.addToAttentionSetOpFactory = addToAttentionSetOpFactory;
     this.removeFromAttentionSetOpFactory = removeFromAttentionSetOpFactory;
@@ -88,6 +91,7 @@
     this.accountResolver = accountResolver;
     this.serviceUserClassifier = serviceUserClassifier;
     this.commentsUtil = commentsUtil;
+    this.draftCommentsReader = draftCommentsReader;
   }
 
   /** Adjusts the attention set but only based on the automatic rules. */
@@ -168,11 +172,13 @@
     List<HumanComment> drafts = new ArrayList<>();
     if (input.drafts == ReviewInput.DraftHandling.PUBLISH) {
       drafts =
-          commentsUtil.draftByPatchSetAuthor(
-              changeNotes.getChange().currentPatchSetId(), currentUser.getAccountId(), changeNotes);
+          draftCommentsReader.getDraftsByPatchSetAndDraftAuthor(
+              changeNotes, changeNotes.getChange().currentPatchSetId(), currentUser.getAccountId());
     }
     if (input.drafts == ReviewInput.DraftHandling.PUBLISH_ALL_REVISIONS) {
-      drafts = commentsUtil.draftByChangeAuthor(changeNotes, currentUser.getAccountId());
+      drafts =
+          draftCommentsReader.getDraftsByChangeAndDraftAuthor(
+              changeNotes, currentUser.getAccountId());
     }
     return Stream.concat(newComments.stream(), drafts.stream()).collect(toImmutableSet());
   }
diff --git a/java/com/google/gerrit/server/restapi/change/Restore.java b/java/com/google/gerrit/server/restapi/change/Restore.java
index 6ac9c21..d33f8df 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import static com.google.gerrit.server.mail.EmailFactories.CHANGE_RESTORED;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
 
@@ -35,9 +36,10 @@
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.extensions.events.ChangeRestored;
+import com.google.gerrit.server.mail.EmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ReplyToChangeSender;
-import com.google.gerrit.server.mail.send.RestoredSender;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -60,7 +62,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final BatchUpdate.Factory updateFactory;
-  private final RestoredSender.Factory restoredSenderFactory;
+  private final EmailFactories emailFactories;
   private final ChangeJson.Factory json;
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
@@ -71,7 +73,7 @@
   @Inject
   Restore(
       BatchUpdate.Factory updateFactory,
-      RestoredSender.Factory restoredSenderFactory,
+      EmailFactories emailFactories,
       ChangeJson.Factory json,
       ChangeMessagesUtil cmUtil,
       PatchSetUtil psUtil,
@@ -79,7 +81,7 @@
       ProjectCache projectCache,
       MessageIdGenerator messageIdGenerator) {
     this.updateFactory = updateFactory;
-    this.restoredSenderFactory = restoredSenderFactory;
+    this.emailFactories = emailFactories;
     this.json = json;
     this.cmUtil = cmUtil;
     this.psUtil = psUtil;
@@ -151,13 +153,16 @@
     @Override
     public void postUpdate(PostUpdateContext ctx) {
       try {
-        ReplyToChangeSender emailSender =
-            restoredSenderFactory.create(ctx.getProject(), change.getId());
-        emailSender.setFrom(ctx.getAccountId());
-        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-        emailSender.setMessageId(
+        ChangeEmail changeEmail =
+            emailFactories.createChangeEmail(
+                ctx.getProject(), change.getId(), emailFactories.createRestoredChangeEmail());
+        changeEmail.setChangeMessage(mailMessage, ctx.getWhen());
+        OutgoingEmail outgoingEmail =
+            emailFactories.createOutgoingEmail(CHANGE_RESTORED, changeEmail);
+        outgoingEmail.setFrom(ctx.getAccountId());
+        outgoingEmail.setMessageId(
             messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
-        emailSender.send();
+        outgoingEmail.send();
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
       }
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index 64cd948..2b0a42c 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.ChangeResource;
@@ -56,7 +57,6 @@
 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.notedb.Sequences;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -86,6 +86,7 @@
 import java.util.Iterator;
 import java.util.List;
 import java.util.Locale;
+import java.util.Optional;
 import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -322,6 +323,13 @@
     cherryPickInput.message = revertInput.message;
     ObjectId generatedChangeId = CommitMessageUtil.generateChangeId();
     Change.Id cherryPickRevertChangeId = Change.id(seq.nextChangeId());
+    RevCommit baseCommit = null;
+    if (cherryPickInput.base != null) {
+      try (Repository git = repoManager.openRepository(changeNotes.getProjectName());
+          RevWalk revWalk = new RevWalk(git.newObjectReader())) {
+        baseCommit = revWalk.parseCommit(ObjectId.fromString(cherryPickInput.base));
+      }
+    }
     try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
       try (BatchUpdate bu = updateFactory.create(project, user.get(), TimeUtil.now())) {
         bu.setNotify(
@@ -335,7 +343,8 @@
                 generatedChangeId,
                 cherryPickRevertChangeId,
                 timestamp,
-                revertInput.workInProgress));
+                revertInput.workInProgress,
+                baseCommit));
         if (!revertInput.workInProgress) {
           commitUtil.addChangeRevertedNotificationOps(
               bu, changeNotes.getChangeId(), cherryPickRevertChangeId, generatedChangeId.name());
@@ -560,18 +569,21 @@
     private final Change.Id cherryPickRevertChangeId;
     private final Instant timestamp;
     private final boolean workInProgress;
+    private final RevCommit baseCommit;
 
     CreateCherryPickOp(
         ObjectId revCommitId,
         ObjectId computedChangeId,
         Change.Id cherryPickRevertChangeId,
         Instant timestamp,
-        Boolean workInProgress) {
+        Boolean workInProgress,
+        RevCommit baseCommit) {
       this.revCommitId = revCommitId;
       this.computedChangeId = computedChangeId;
       this.cherryPickRevertChangeId = cherryPickRevertChangeId;
       this.timestamp = timestamp;
       this.workInProgress = workInProgress;
+      this.baseCommit = baseCommit;
     }
 
     @Override
@@ -589,7 +601,8 @@
               change.getId(),
               computedChangeId,
               cherryPickRevertChangeId,
-              workInProgress);
+              workInProgress,
+              Optional.ofNullable(baseCommit));
       // save the commit as base for next cherryPick of that branch
       cherryPickInput.base =
           changeNotesFactory
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
index 07e54ce..a6600d7 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -94,13 +94,12 @@
   public List<Account.Id> suggestReviewers(
       ReviewerState reviewerState,
       @Nullable ChangeNotes changeNotes,
-      SuggestReviewers suggestReviewers,
+      String query,
       ProjectState projectState,
       List<Account.Id> candidateList)
       throws IOException, ConfigInvalidException {
     logger.atFine().log("Candidates %s", candidateList);
 
-    String query = suggestReviewers.getQuery();
     logger.atFine().log("query: %s", query);
 
     double baseWeight = config.getInt("addReviewer", "baseWeight", 1);
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
index cfb3506..6b6e5fc 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -343,7 +343,7 @@
       throws IOException, ConfigInvalidException {
     try (Timer0.Context ctx = metrics.recommendAccountsLatency.start()) {
       return reviewerRecommender.suggestReviewers(
-          reviewerState, changeNotes, suggestReviewers, projectState, candidateList);
+          reviewerState, changeNotes, suggestReviewers.getQuery(), projectState, candidateList);
     }
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
index 7d3fe98..97b08f0 100644
--- a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
+++ b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
@@ -77,7 +77,7 @@
         bu.addOp(rsrc.getChange().getId(), opFactory.create(false, input));
         if (change.getRevertOf() != null) {
           commitUtil.addChangeRevertedNotificationOps(
-              bu, change.getRevertOf(), change.getId(), change.getKey().get());
+              bu, change.getRevertOf(), change.getId(), change.getKey().get().substring(1));
         }
         bu.execute();
         return Response.ok();
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index 21095a0..fddcfa4 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -22,7 +22,6 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 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;
@@ -83,6 +82,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 @Singleton
@@ -286,7 +286,7 @@
         }
       }
 
-      Collection<ChangeData> unmergeable = unmergeableChanges(cs);
+      Collection<ChangeData> unmergeable = getUnmergeableChanges(cs);
       if (unmergeable == null) {
         return CLICK_FAILURE_TOOLTIP;
       } else if (!unmergeable.isEmpty()) {
@@ -383,36 +383,27 @@
   }
 
   @Nullable
-  public Collection<ChangeData> unmergeableChanges(ChangeSet cs) throws IOException {
-    Set<ChangeData> mergeabilityMap = new HashSet<>();
-    Set<ObjectId> outDatedPatchsets = new HashSet<>();
+  public Collection<ChangeData> getUnmergeableChanges(ChangeSet cs) throws IOException {
+    Set<ChangeData> unmergeableChanges = new HashSet<>();
+    Set<ObjectId> outDatedPatchSets = new HashSet<>();
     for (ChangeData change : cs.changes()) {
-      mergeabilityMap.add(change);
-      // Add all the patchsets commit ids except the current patchset.
-      outDatedPatchsets.addAll(
-          change.notes().getPatchSets().values().stream()
-              .map(p -> p.commitId())
-              .collect(Collectors.toSet()));
-      outDatedPatchsets.remove(change.currentPatchSet().commitId());
+      unmergeableChanges.add(change);
+      addAllOutdatedPatchSets(outDatedPatchSets, change);
     }
-
     ListMultimap<BranchNameKey, ChangeData> cbb = cs.changesByBranch();
     for (BranchNameKey branch : cbb.keySet()) {
       Collection<ChangeData> targetBranch = cbb.get(branch);
-      HashMap<Change.Id, RevCommit> commits = findCommits(targetBranch, branch.project());
-
-      Set<ObjectId> allParents = Sets.newHashSetWithExpectedSize(cs.size());
-      for (RevCommit commit : commits.values()) {
-        for (RevCommit parent : commit.getParents()) {
-          allParents.add(parent.getId());
-        }
-      }
+      HashMap<Change.Id, RevCommit> commits = mapToCommits(targetBranch, branch.project());
+      Set<ObjectId> allParents =
+          commits.values().stream()
+              .flatMap(c -> Arrays.stream(c.getParents()))
+              .map(RevObject::getId)
+              .collect(Collectors.toSet());
       for (ChangeData change : targetBranch) {
-
         RevCommit commit = commits.get(change.getId());
         boolean isMergeCommit = commit.getParentCount() > 1;
         boolean isLastInChain = !allParents.contains(commit.getId());
-        if (Arrays.stream(commit.getParents()).anyMatch(c -> outDatedPatchsets.contains(c.getId()))
+        if (Arrays.stream(commit.getParents()).anyMatch(c -> outDatedPatchSets.contains(c.getId()))
             && !isCherryPickSubmit(change)) {
           // Found a parent that depends on an outdated patchset and the submit strategy is not
           // cherry-pick.
@@ -429,18 +420,26 @@
           return null;
         }
         if (mergeable) {
-          mergeabilityMap.remove(change);
+          unmergeableChanges.remove(change);
         }
-
         if (isLastInChain && isMergeCommit && mergeable) {
-          for (ChangeData c : targetBranch) {
-            mergeabilityMap.remove(c);
-          }
+          targetBranch.stream().forEach(unmergeableChanges::remove);
           break;
         }
       }
     }
-    return mergeabilityMap;
+    return unmergeableChanges;
+  }
+
+  /**
+   * Add all outdated patch-sets (non-last patch-sets) to the output set {@code outdatedPatchSets}.
+   */
+  private static void addAllOutdatedPatchSets(Set<ObjectId> outdatedPatchSets, ChangeData cd) {
+    outdatedPatchSets.addAll(
+        cd.notes().getPatchSets().values().stream()
+            .map(p -> p.commitId())
+            .collect(Collectors.toSet()));
+    outdatedPatchSets.remove(cd.currentPatchSet().commitId());
   }
 
   private boolean isCherryPickSubmit(ChangeData changeData) {
@@ -448,7 +447,8 @@
     return submitTypeRecord.isOk() && submitTypeRecord.type == SubmitType.CHERRY_PICK;
   }
 
-  private HashMap<Change.Id, RevCommit> findCommits(
+  /** Map input {@code changes} to the commit SHA-1 of their latest patch-set. */
+  private HashMap<Change.Id, RevCommit> mapToCommits(
       Collection<ChangeData> changes, Project.NameKey project) throws IOException {
     HashMap<Change.Id, RevCommit> commits = new HashMap<>();
     try (Repository repo = repoManager.openRepository(project);
diff --git a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
index 97f866b..cc607f4 100644
--- a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
+++ b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
@@ -31,9 +31,7 @@
 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.gerrit.server.rules.PrologSubmitRuleUtil;
 import com.google.inject.Inject;
 import java.util.LinkedHashMap;
 import java.util.Optional;
@@ -41,10 +39,9 @@
 
 public class TestSubmitRule implements RestModifyView<RevisionResource, TestSubmitRuleInput> {
   private final ChangeData.Factory changeDataFactory;
-  private final RulesCache rules;
   private final AccountLoader.Factory accountInfoFactory;
   private final ProjectCache projectCache;
-  private final PrologRule prologRule;
+  private final PrologSubmitRuleUtil prologSubmitRuleUtil;
 
   @Option(name = "--filters", usage = "impact of filters in parent projects")
   private Filters filters = Filters.RUN;
@@ -52,15 +49,13 @@
   @Inject
   TestSubmitRule(
       ChangeData.Factory changeDataFactory,
-      RulesCache rules,
       AccountLoader.Factory infoFactory,
       ProjectCache projectCache,
-      PrologRule prologRule) {
+      PrologSubmitRuleUtil prologSubmitRuleUtil) {
     this.changeDataFactory = changeDataFactory;
-    this.rules = rules;
     this.accountInfoFactory = infoFactory;
     this.projectCache = projectCache;
-    this.prologRule = prologRule;
+    this.prologSubmitRuleUtil = prologSubmitRuleUtil;
   }
 
   @Override
@@ -72,7 +67,7 @@
     if (input.rule == null) {
       throw new BadRequestException("rule is required");
     }
-    if (!rules.isProjectRulesEnabled()) {
+    if (!prologSubmitRuleUtil.isProjectRulesEnabled()) {
       throw new AuthException("project rules are disabled");
     }
     input.filters = MoreObjects.firstNonNull(input.filters, filters);
@@ -84,8 +79,7 @@
     }
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
     SubmitRecord record =
-        prologRule.evaluate(
-            cd, PrologOptions.dryRunOptions(input.rule, input.filters == Filters.SKIP));
+        prologSubmitRuleUtil.evaluate(cd, input.rule, input.filters == Filters.SKIP);
 
     AccountLoader accounts = accountInfoFactory.create(true);
     TestSubmitRuleInfo out = newSubmitRuleInfo(record, accounts);
diff --git a/java/com/google/gerrit/server/restapi/change/TestSubmitType.java b/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
index ecb455e..7a47b92 100644
--- a/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
+++ b/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
@@ -29,25 +29,21 @@
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.rules.PrologOptions;
-import com.google.gerrit.server.rules.PrologRule;
-import com.google.gerrit.server.rules.RulesCache;
+import com.google.gerrit.server.rules.PrologSubmitRuleUtil;
 import com.google.inject.Inject;
 import org.kohsuke.args4j.Option;
 
 public class TestSubmitType implements RestModifyView<RevisionResource, TestSubmitRuleInput> {
   private final ChangeData.Factory changeDataFactory;
-  private final RulesCache rules;
-  private final PrologRule prologRule;
+  private final PrologSubmitRuleUtil prologSubmitRuleUtil;
 
   @Option(name = "--filters", usage = "impact of filters in parent projects")
   private Filters filters = Filters.RUN;
 
   @Inject
-  TestSubmitType(ChangeData.Factory changeDataFactory, RulesCache rules, PrologRule prologRule) {
+  TestSubmitType(ChangeData.Factory changeDataFactory, PrologSubmitRuleUtil prologRule) {
     this.changeDataFactory = changeDataFactory;
-    this.rules = rules;
-    this.prologRule = prologRule;
+    this.prologSubmitRuleUtil = prologRule;
   }
 
   @Override
@@ -59,15 +55,14 @@
     if (input.rule == null) {
       throw new BadRequestException("rule is required");
     }
-    if (!rules.isProjectRulesEnabled()) {
+    if (!prologSubmitRuleUtil.isProjectRulesEnabled()) {
       throw new AuthException("project rules are disabled");
     }
     input.filters = MoreObjects.firstNonNull(input.filters, filters);
 
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
     SubmitTypeRecord rec =
-        prologRule.getSubmitType(
-            cd, PrologOptions.dryRunOptions(input.rule, input.filters == Filters.SKIP));
+        prologSubmitRuleUtil.getSubmitType(cd, input.rule, input.filters == Filters.SKIP);
 
     if (rec.status != SubmitTypeRecord.Status.OK) {
       throw new BadRequestException(String.format("rule produced invalid result: %s", rec));
diff --git a/java/com/google/gerrit/server/restapi/config/CheckConsistency.java b/java/com/google/gerrit/server/restapi/config/CheckConsistency.java
index 50e774a..40b406c 100644
--- a/java/com/google/gerrit/server/restapi/config/CheckConsistency.java
+++ b/java/com/google/gerrit/server/restapi/config/CheckConsistency.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountsConsistencyChecker;
 import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker;
 import com.google.gerrit.server.config.ConfigResource;
@@ -41,17 +42,20 @@
   private final AccountsConsistencyChecker accountsConsistencyChecker;
   private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker;
   private final GroupsConsistencyChecker groupsConsistencyChecker;
+  private final AccountCache accountCache;
 
   @Inject
   CheckConsistency(
       PermissionBackend permissionBackend,
       AccountsConsistencyChecker accountsConsistencyChecker,
       ExternalIdsConsistencyChecker externalIdsConsistencyChecker,
-      GroupsConsistencyChecker groupsChecker) {
+      GroupsConsistencyChecker groupsChecker,
+      AccountCache accountCache) {
     this.permissionBackend = permissionBackend;
     this.accountsConsistencyChecker = accountsConsistencyChecker;
     this.externalIdsConsistencyChecker = externalIdsConsistencyChecker;
     this.groupsConsistencyChecker = groupsChecker;
+    this.accountCache = accountCache;
   }
 
   @Override
@@ -73,7 +77,7 @@
     }
     if (input.checkAccountExternalIds != null) {
       consistencyCheckInfo.checkAccountExternalIdsResult =
-          new CheckAccountExternalIdsResultInfo(externalIdsConsistencyChecker.check());
+          new CheckAccountExternalIdsResultInfo(externalIdsConsistencyChecker.check(accountCache));
     }
 
     if (input.checkGroups != null) {
diff --git a/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java b/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
index 427ff84..3de05e9 100644
--- a/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
@@ -29,22 +29,27 @@
     DynamicMap.mapOf(binder(), CONFIG_KIND);
     DynamicMap.mapOf(binder(), TASK_KIND);
     DynamicMap.mapOf(binder(), TopMenuResource.TOP_MENU_KIND);
+
     child(CONFIG_KIND, "capabilities").to(CapabilitiesCollection.class);
-    child(CONFIG_KIND, "tasks").to(TasksCollection.class);
-    get(TASK_KIND).to(GetTask.class);
-    delete(TASK_KIND).to(DeleteTask.class);
-    child(CONFIG_KIND, "top-menus").to(TopMenuCollection.class);
-    get(CONFIG_KIND, "version").to(GetVersion.class);
-    get(CONFIG_KIND, "info").to(GetServerInfo.class);
     post(CONFIG_KIND, "check.consistency").to(CheckConsistency.class);
+    put(CONFIG_KIND, "email.confirm").to(ConfirmEmail.class);
     post(CONFIG_KIND, "index.changes").to(IndexChanges.class);
-    post(CONFIG_KIND, "reload").to(ReloadConfig.class);
+    get(CONFIG_KIND, "info").to(GetServerInfo.class);
     get(CONFIG_KIND, "preferences").to(GetPreferences.class);
     put(CONFIG_KIND, "preferences").to(SetPreferences.class);
     get(CONFIG_KIND, "preferences.diff").to(GetDiffPreferences.class);
     put(CONFIG_KIND, "preferences.diff").to(SetDiffPreferences.class);
     get(CONFIG_KIND, "preferences.edit").to(GetEditPreferences.class);
     put(CONFIG_KIND, "preferences.edit").to(SetEditPreferences.class);
-    put(CONFIG_KIND, "email.confirm").to(ConfirmEmail.class);
+    post(CONFIG_KIND, "reload").to(ReloadConfig.class);
+
+    child(CONFIG_KIND, "tasks").to(TasksCollection.class);
+    delete(TASK_KIND).to(DeleteTask.class);
+    get(TASK_KIND).to(GetTask.class);
+
+    child(CONFIG_KIND, "top-menus").to(TopMenuCollection.class);
+    get(CONFIG_KIND, "version").to(GetVersion.class);
+
+    // The caches and summary REST endpoints are bound via RestCacheAdminModule.
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index 63f2239..9e0eac7 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -294,6 +294,7 @@
         toBoolean(enableSignedPush && config.getBoolean("gerrit", null, "editGpgKeys", true));
     info.primaryWeblinkName = config.getString("gerrit", null, "primaryWeblinkName");
     info.instanceId = instanceId;
+    info.defaultBranch = config.getString("gerrit", null, "defaultBranch");
     return info;
   }
 
diff --git a/java/com/google/gerrit/server/restapi/config/GetSummary.java b/java/com/google/gerrit/server/restapi/config/GetSummary.java
index 34cf550..faa3871 100644
--- a/java/com/google/gerrit/server/restapi/config/GetSummary.java
+++ b/java/com/google/gerrit/server/restapi/config/GetSummary.java
@@ -48,14 +48,6 @@
   private final WorkQueue workQueue;
   private final Path sitePath;
 
-  @Option(name = "--gc", usage = "perform Java GC before retrieving memory stats")
-  private boolean gc;
-
-  public GetSummary setGc(boolean gc) {
-    this.gc = gc;
-    return this;
-  }
-
   @Option(name = "--jvm", usage = "include details about the JVM")
   private boolean jvm;
 
@@ -72,12 +64,6 @@
 
   @Override
   public Response<SummaryInfo> apply(ConfigResource rsrc) {
-    if (gc) {
-      System.gc();
-      System.runFinalization();
-      System.gc();
-    }
-
     SummaryInfo summary = new SummaryInfo();
     summary.taskSummary = getTaskSummary();
     summary.memSummary = getMemSummary();
diff --git a/java/com/google/gerrit/server/restapi/config/GetVersion.java b/java/com/google/gerrit/server/restapi/config/GetVersion.java
index ee206d6..6b205e4 100644
--- a/java/com/google/gerrit/server/restapi/config/GetVersion.java
+++ b/java/com/google/gerrit/server/restapi/config/GetVersion.java
@@ -14,23 +14,40 @@
 
 package com.google.gerrit.server.restapi.config;
 
-import com.google.gerrit.common.Version;
+import com.google.gerrit.extensions.common.VersionInfo;
 import com.google.gerrit.extensions.restapi.CacheControl;
 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.config.ConfigResource;
-import com.google.inject.Singleton;
+import com.google.inject.Inject;
 import java.util.concurrent.TimeUnit;
+import org.kohsuke.args4j.Option;
 
-@Singleton
 public class GetVersion implements RestReadView<ConfigResource> {
+
+  @Option(
+      name = "--verbose",
+      aliases = {"-v"},
+      usage = "verbose version info")
+  boolean verbose;
+
+  private final VersionInfo versionInfo;
+
+  @Inject
+  public GetVersion(VersionInfo versionInfo) {
+    this.versionInfo = versionInfo;
+  }
+
   @Override
-  public Response<String> apply(ConfigResource resource) throws ResourceNotFoundException {
-    String version = Version.getVersion();
-    if (version == null) {
+  public Response<?> apply(ConfigResource resource) throws ResourceNotFoundException {
+    if (versionInfo.gerritVersion == null) {
       throw new ResourceNotFoundException();
     }
-    return Response.ok(version).caching(CacheControl.PRIVATE(30, TimeUnit.SECONDS));
+    if (verbose) {
+      return Response.ok(versionInfo).caching(CacheControl.PRIVATE(30, TimeUnit.SECONDS));
+    }
+    return Response.ok(versionInfo.gerritVersion)
+        .caching(CacheControl.PRIVATE(30, TimeUnit.SECONDS));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/config/RestCacheAdminModule.java b/java/com/google/gerrit/server/restapi/config/RestCacheAdminModule.java
index c929bc6..701d144d 100644
--- a/java/com/google/gerrit/server/restapi/config/RestCacheAdminModule.java
+++ b/java/com/google/gerrit/server/restapi/config/RestCacheAdminModule.java
@@ -25,10 +25,12 @@
   @Override
   protected void configure() {
     DynamicMap.mapOf(binder(), CACHE_KIND);
+
     child(CONFIG_KIND, "caches").to(CachesCollection.class);
     postOnCollection(CACHE_KIND).to(PostCaches.class);
     get(CACHE_KIND).to(GetCache.class);
     post(CACHE_KIND, "flush").to(FlushCache.class);
+
     get(CONFIG_KIND, "summary").to(GetSummary.class);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index ea6fa79..ea15f12 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.CreateGroupArgs;
 import com.google.gerrit.server.account.GroupCache;
@@ -52,7 +53,6 @@
 import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupCreation;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.util.time.TimeUtil;
diff --git a/java/com/google/gerrit/server/restapi/group/GroupRestApiModule.java b/java/com/google/gerrit/server/restapi/group/GroupRestApiModule.java
index 8024862..f115374 100644
--- a/java/com/google/gerrit/server/restapi/group/GroupRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/group/GroupRestApiModule.java
@@ -20,17 +20,12 @@
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.ServerInitiated;
-import com.google.gerrit.server.UserInitiated;
-import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.restapi.group.AddMembers.CreateMember;
 import com.google.gerrit.server.restapi.group.AddMembers.UpdateMember;
 import com.google.gerrit.server.restapi.group.AddSubgroups.CreateSubgroup;
 import com.google.gerrit.server.restapi.group.AddSubgroups.UpdateSubgroup;
 import com.google.gerrit.server.restapi.group.DeleteMembers.DeleteMember;
 import com.google.gerrit.server.restapi.group.DeleteSubgroups.DeleteSubgroup;
-import com.google.inject.Provides;
 
 public class GroupRestApiModule extends RestApiModule {
 
@@ -45,24 +40,23 @@
     create(GROUP_KIND).to(CreateGroup.class);
     get(GROUP_KIND).to(GetGroup.class);
     put(GROUP_KIND).to(PutGroup.class);
-    get(GROUP_KIND, "detail").to(GetDetail.class);
-    post(GROUP_KIND, "index").to(Index.class);
-    post(GROUP_KIND, "members").to(AddMembers.class);
-    post(GROUP_KIND, "members.add").to(AddMembers.class);
-    post(GROUP_KIND, "members.delete").to(DeleteMembers.class);
-    post(GROUP_KIND, "groups").to(AddSubgroups.class);
-    post(GROUP_KIND, "groups.add").to(AddSubgroups.class);
-    post(GROUP_KIND, "groups.delete").to(DeleteSubgroups.class);
     get(GROUP_KIND, "description").to(GetDescription.class);
     put(GROUP_KIND, "description").to(PutDescription.class);
     delete(GROUP_KIND, "description").to(PutDescription.class);
-    get(GROUP_KIND, "name").to(GetName.class);
-    put(GROUP_KIND, "name").to(PutName.class);
-    get(GROUP_KIND, "owner").to(GetOwner.class);
-    put(GROUP_KIND, "owner").to(PutOwner.class);
-    get(GROUP_KIND, "options").to(GetOptions.class);
-    put(GROUP_KIND, "options").to(PutOptions.class);
+    get(GROUP_KIND, "detail").to(GetDetail.class);
+    post(GROUP_KIND, "groups").to(AddSubgroups.class);
+
+    child(GROUP_KIND, "groups").to(SubgroupsCollection.class);
+    create(SUBGROUP_KIND).to(CreateSubgroup.class);
+    delete(SUBGROUP_KIND).to(DeleteSubgroup.class);
+    get(SUBGROUP_KIND).to(GetSubgroup.class);
+    put(SUBGROUP_KIND).to(UpdateSubgroup.class);
+
+    post(GROUP_KIND, "groups.add").to(AddSubgroups.class);
+    post(GROUP_KIND, "groups.delete").to(DeleteSubgroups.class);
+    post(GROUP_KIND, "index").to(Index.class);
     get(GROUP_KIND, "log.audit").to(GetAuditLog.class);
+    post(GROUP_KIND, "members").to(AddMembers.class);
 
     child(GROUP_KIND, "members").to(MembersCollection.class);
     create(MEMBER_KIND).to(CreateMember.class);
@@ -70,25 +64,13 @@
     put(MEMBER_KIND).to(UpdateMember.class);
     delete(MEMBER_KIND).to(DeleteMember.class);
 
-    child(GROUP_KIND, "groups").to(SubgroupsCollection.class);
-    create(SUBGROUP_KIND).to(CreateSubgroup.class);
-    get(SUBGROUP_KIND).to(GetSubgroup.class);
-    put(SUBGROUP_KIND).to(UpdateSubgroup.class);
-    delete(SUBGROUP_KIND).to(DeleteSubgroup.class);
-
-    factory(GroupsUpdate.Factory.class);
-  }
-
-  @Provides
-  @ServerInitiated
-  GroupsUpdate provideServerInitiatedGroupsUpdate(GroupsUpdate.Factory groupsUpdateFactory) {
-    return groupsUpdateFactory.createWithServerIdent();
-  }
-
-  @Provides
-  @UserInitiated
-  GroupsUpdate provideUserInitiatedGroupsUpdate(
-      GroupsUpdate.Factory groupsUpdateFactory, IdentifiedUser currentUser) {
-    return groupsUpdateFactory.create(currentUser);
+    post(GROUP_KIND, "members.add").to(AddMembers.class);
+    post(GROUP_KIND, "members.delete").to(DeleteMembers.class);
+    get(GROUP_KIND, "name").to(GetName.class);
+    put(GROUP_KIND, "name").to(PutName.class);
+    get(GROUP_KIND, "options").to(GetOptions.class);
+    put(GROUP_KIND, "options").to(PutOptions.class);
+    get(GROUP_KIND, "owner").to(GetOwner.class);
+    put(GROUP_KIND, "owner").to(PutOwner.class);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/AbstractListProjects.java b/java/com/google/gerrit/server/restapi/project/AbstractListProjects.java
new file mode 100644
index 0000000..6467d81
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/AbstractListProjects.java
@@ -0,0 +1,118 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.json.OutputFormat;
+import org.kohsuke.args4j.Option;
+
+/**
+ * Base class for {@link ListProjects} implementations.
+ *
+ * <p>Defines the options that are supported by the list projects REST endpoint.
+ */
+public abstract class AbstractListProjects implements ListProjects {
+  @Override
+  @Option(name = "--format", usage = "(deprecated) output format")
+  public abstract void setFormat(OutputFormat fmt);
+
+  @Override
+  @Option(
+      name = "--show-branch",
+      aliases = {"-b"},
+      usage = "displays the sha of each project in the specified branch")
+  public abstract void addShowBranch(String branch);
+
+  @Override
+  @Option(
+      name = "--tree",
+      aliases = {"-t"},
+      usage =
+          "displays project inheritance in a tree-like format\n"
+              + "this option does not work together with the show-branch option")
+  public abstract void setShowTree(boolean showTree);
+
+  @Override
+  @Option(name = "--type", usage = "type of project")
+  public abstract void setFilterType(FilterType type);
+
+  @Override
+  @Option(
+      name = "--description",
+      aliases = {"-d"},
+      usage = "include description of project in list")
+  public abstract void setShowDescription(boolean showDescription);
+
+  @Override
+  @Option(name = "--all", usage = "display all projects that are accessible by the calling user")
+  public abstract void setAll(boolean all);
+
+  @Override
+  @Option(
+      name = "--state",
+      aliases = {"-s"},
+      usage = "filter by project state")
+  public abstract void setState(com.google.gerrit.extensions.client.ProjectState state);
+
+  @Override
+  @Option(
+      name = "--limit",
+      aliases = {"-n"},
+      metaVar = "CNT",
+      usage = "maximum number of projects to list")
+  public abstract void setLimit(int limit);
+
+  @Override
+  @Option(
+      name = "--start",
+      aliases = {"-S"},
+      metaVar = "CNT",
+      usage = "number of projects to skip")
+  public abstract void setStart(int start);
+
+  @Override
+  @Option(
+      name = "--prefix",
+      aliases = {"-p"},
+      metaVar = "PREFIX",
+      usage = "match project prefix")
+  public abstract void setMatchPrefix(String matchPrefix);
+
+  @Override
+  @Option(
+      name = "--match",
+      aliases = {"-m"},
+      metaVar = "MATCH",
+      usage = "match project substring")
+  public abstract void setMatchSubstring(String matchSubstring);
+
+  @Override
+  @Option(name = "-r", metaVar = "REGEX", usage = "match project regex")
+  public abstract void setMatchRegex(String matchRegex);
+
+  @Override
+  @Option(
+      name = "--has-acl-for",
+      metaVar = "GROUP",
+      usage = "displays only projects on which access rights for this group are directly assigned")
+  public abstract void setGroupUuid(AccountGroup.UUID groupUuid);
+
+  @Override
+  public Response<Object> apply(TopLevelResource resource) throws Exception {
+    return Response.ok(apply());
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
index 09951b2..e0c699a 100644
--- a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
@@ -143,37 +143,41 @@
     if (!changes.isEmpty()) {
       return true;
     }
+    if (commit.getParents() != null && commit.getParents().length > 0) {
+      // Maybe the commit was a merge commit of a change. Try to find promising candidates for
+      // branches to check, by seeing if its parents were associated to changes.
+      // Only request changes from the index if the commit has parents. If size(parents) == 0, then
+      // the query does not make sense (it would request all changes from the project).
+      ImmutableList<Predicate<ChangeData>> parentPredicates =
+          Arrays.stream(commit.getParents())
+              .map(parent -> ChangePredicates.commitPrefix(parent.getId().getName()))
+              .collect(toImmutableList());
+      Predicate<ChangeData> pred =
+          Predicate.and(ChangePredicates.project(project), Predicate.or(parentPredicates));
+      changes =
+          retryHelper
+              .changeIndexQuery(
+                  "queryChangesByProjectCommit", q -> q.enforceVisibility(true).query(pred))
+              .call();
+      Set<Ref> branchesForCommitParents = new HashSet<>(changes.size());
+      for (ChangeData cd : changes) {
+        Ref ref = repo.exactRef(cd.change().getDest().branch());
+        if (ref != null) {
+          branchesForCommitParents.add(ref);
+        }
+      }
 
-    // Maybe the commit was a merge commit of a change. Try to find promising candidates for
-    // branches to check, by seeing if its parents were associated to changes.
-    Predicate<ChangeData> pred =
-        Predicate.and(
-            ChangePredicates.project(project),
-            Predicate.or(
-                Arrays.stream(commit.getParents())
-                    .map(parent -> ChangePredicates.commitPrefix(parent.getId().getName()))
-                    .collect(toImmutableList())));
-    changes =
-        retryHelper
-            .changeIndexQuery(
-                "queryChangesByProjectCommit", q -> q.enforceVisibility(true).query(pred))
-            .call();
-
-    Set<Ref> branchesForCommitParents = new HashSet<>(changes.size());
-    for (ChangeData cd : changes) {
-      Ref ref = repo.exactRef(cd.change().getDest().branch());
-      if (ref != null) {
-        branchesForCommitParents.add(ref);
+      if (reachable.fromRefs(
+          project, repo, commit, branchesForCommitParents.stream().collect(Collectors.toList()))) {
+        return true;
       }
     }
+    // This check covers 2 situations:
+    // 1) The commit does not have any parents. Check if it is visible from any ref in the project.
+    // Exclude change refs, since it is confirmed the commit is not a patchset of any change.
 
-    if (reachable.fromRefs(
-        project, repo, commit, branchesForCommitParents.stream().collect(Collectors.toList()))) {
-      return true;
-    }
-
-    // If we have already checked change refs using the change index, spare any further checks for
-    // changes.
+    // 2) If we have already checked change refs using the change index, spare any further checks
+    // for changes.
     List<Ref> refs =
         repo.getRefDatabase()
             .getRefsByPrefixWithExclusions(RefDatabase.ALL, ImmutableSet.of(RefNames.REFS_CHANGES));
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index 3dae161..3db3fa0 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -33,11 +33,11 @@
 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.Sequences;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.ProjectPermission;
diff --git a/java/com/google/gerrit/server/restapi/project/CreateBranch.java b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
index eee2f21..6dc84d8 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
@@ -212,8 +212,8 @@
           info.canDelete = null;
         } else {
           info.canDelete =
-              permissionBackend.currentUser().ref(name).testOrFalse(RefPermission.DELETE)
-                      && rsrc.getProjectState().statePermitsWrite()
+              (permissionBackend.currentUser().ref(name).testOrFalse(RefPermission.DELETE)
+                      && rsrc.getProjectState().statePermitsWrite())
                   ? true
                   : null;
         }
diff --git a/java/com/google/gerrit/server/restapi/project/CreateProject.java b/java/com/google/gerrit/server/restapi/project/CreateProject.java
index ecf641c..24529fb 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateProject.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -227,7 +227,7 @@
     return branch;
   }
 
-  static class ValidBranchListener implements ProjectCreationValidationListener {
+  public static class ValidBranchListener implements ProjectCreationValidationListener {
     @Override
     public void validateNewProject(CreateProjectArgs args) throws ValidationException {
       for (String branch : args.branch) {
diff --git a/java/com/google/gerrit/server/restapi/project/ListBranches.java b/java/com/google/gerrit/server/restapi/project/ListBranches.java
index 2c26933..3cb412a 100644
--- a/java/com/google/gerrit/server/restapi/project/ListBranches.java
+++ b/java/com/google/gerrit/server/restapi/project/ListBranches.java
@@ -16,9 +16,13 @@
 
 import static com.google.gerrit.entities.RefNames.isConfigRef;
 
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.Sets;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
@@ -26,12 +30,15 @@
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 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.extensions.restapi.RestView;
 import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.proto.Entities.PaginationToken;
+import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.extensions.webui.UiActions;
@@ -45,12 +52,17 @@
 import com.google.gerrit.server.project.RefFilter;
 import com.google.inject.Inject;
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
 import java.util.Collection;
 import java.util.Comparator;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import java.util.TreeMap;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Ref;
@@ -58,6 +70,10 @@
 import org.kohsuke.args4j.Option;
 
 public class ListBranches implements RestReadView<ProjectResource> {
+  public static final String NEXT_PAGE_TOKEN_HEADER = "X-GERRIT-NEXT-PAGE-TOKEN";
+  private static final String ENCODED_HEADER = encodeImpl(NEXT_PAGE_TOKEN_HEADER);
+  private static final RefNameComparator REF_NAME_COMPARATOR = new RefNameComparator();
+
   private final GitRepositoryManager repoManager;
   private final PermissionBackend permissionBackend;
   private final DynamicMap<RestView<BranchResource>> branchViews;
@@ -83,6 +99,15 @@
   }
 
   @Option(
+      name = "--next-page-token",
+      aliases = {"-t"},
+      metaVar = "CNT",
+      usage = "continuation token that can be used to skip some branches")
+  public void setNextPageToken(String token) {
+    this.nextPageToken = token;
+  }
+
+  @Option(
       name = "--match",
       aliases = {"-m"},
       metaVar = "MATCH",
@@ -102,6 +127,7 @@
 
   private int limit;
   private int start;
+  private String nextPageToken;
   private String matchSubstring;
   private String matchRegex;
 
@@ -122,22 +148,61 @@
   public ListBranches request(ListRefsRequest<BranchInfo> request) {
     this.setLimit(request.getLimit());
     this.setStart(request.getStart());
+    this.setNextPageToken(request.getNextPageToken());
     this.setMatchSubstring(request.getSubstring());
     this.setMatchRegex(request.getRegex());
     return this;
   }
 
+  @AutoValue
+  abstract static class ListBranchResult {
+    /** List of branches in the result set. */
+    abstract ImmutableList<BranchInfo> list();
+
+    /** Indicates if there are more results. */
+    abstract boolean hasMore();
+
+    static ListBranchResult create(ImmutableList<BranchInfo> list, boolean hasMore) {
+      return new AutoValue_ListBranches_ListBranchResult(list, hasMore);
+    }
+  }
+
   @Override
   public Response<ImmutableList<BranchInfo>> apply(ProjectResource rsrc)
       throws RestApiException, IOException, PermissionBackendException {
     rsrc.getProjectState().checkStatePermitsRead();
-    return Response.ok(
-        new RefFilter<BranchInfo>(Constants.R_HEADS)
-            .subString(matchSubstring)
-            .regex(matchRegex)
-            .start(start)
-            .limit(limit)
-            .filter(allBranches(rsrc)));
+
+    if (start > 0 && nextPageToken != null) {
+      throw new BadRequestException(
+          "'start' and 'next-page-token' parameters are mutually exclusive.");
+    }
+
+    // Filter on refs/heads/*, substring and regex without checking ref visibility
+    List<Ref> allBranches = readAllBranches(rsrc);
+    Set<String> targets = getTargets(allBranches);
+    ImmutableList<Ref> filtered =
+        new RefFilter<>(Constants.R_HEADS, (Ref r) -> r.getName())
+            .subString(matchSubstring).regex(matchRegex).filter(allBranches).stream()
+                .sorted(new RefComparator())
+                .collect(ImmutableList.toImmutableList());
+
+    if (nextPageToken != null) {
+      if (!isValidToken(nextPageToken)) {
+        throw new BadRequestException(
+            "Invalid 'next-page-token'. This token was not created by the Gerrit server.");
+      }
+      filtered = filterUsingNextPageToken(filtered);
+    }
+
+    // Filter for visibility, taking 'start' and 'limit' parameters into account
+    ListBranchResult result = filterForVisibility(rsrc, filtered, targets);
+    return result.hasMore()
+        ? Response.ok(
+            result.list(),
+            ImmutableMultimap.of(
+                NEXT_PAGE_TOKEN_HEADER,
+                encodeToken(result.list().get(result.list().size() - 1).ref)))
+        : Response.ok(result.list());
   }
 
   BranchInfo toBranchInfo(BranchResource rsrc)
@@ -151,14 +216,20 @@
       if (r == null) {
         throw new ResourceNotFoundException();
       }
-      return toBranchInfo(rsrc, ImmutableList.of(r)).get(0);
+      return toBranchInfo(
+              r,
+              getTargets(ImmutableList.of(r)),
+              rsrc.getNameKey(),
+              rsrc.getProjectState(),
+              rsrc.getUser())
+          .get();
     } catch (RepositoryNotFoundException noRepo) {
       throw new ResourceNotFoundException(rsrc.getNameKey().get(), noRepo);
     }
   }
 
-  private List<BranchInfo> allBranches(ProjectResource rsrc)
-      throws IOException, ResourceNotFoundException, PermissionBackendException {
+  private List<Ref> readAllBranches(ProjectResource rsrc)
+      throws IOException, ResourceNotFoundException {
     List<Ref> refs;
     try (Repository db = repoManager.openRepository(rsrc.getNameKey())) {
       Collection<Ref> heads = db.getRefDatabase().getRefsByPrefix(Constants.R_HEADS);
@@ -168,89 +239,164 @@
           db.getRefDatabase()
               .exactRef(Constants.HEAD, RefNames.REFS_CONFIG, RefNames.REFS_USERS_DEFAULT)
               .values());
+      return refs;
     } catch (RepositoryNotFoundException noGitRepository) {
       throw new ResourceNotFoundException(rsrc.getNameKey().get(), noGitRepository);
     }
-    return toBranchInfo(rsrc, refs);
   }
 
-  private List<BranchInfo> toBranchInfo(ProjectResource rsrc, List<Ref> refs)
-      throws PermissionBackendException {
-    Set<String> targets = Sets.newHashSetWithExpectedSize(1);
+  /**
+   * Filter the input {@code refs} list w.r.t. current user's visibility of the ref. This also takes
+   * into account the {@link #start} and {@link #limit} parameters. We check refs iteratively while
+   * keeping track of matching (visible) refs. We only populate the output list if the matching ref
+   * ordinal is greater or equal {@link #start} and keep filling the output list until a {@link
+   * #limit} number of refs is gotten.
+   */
+  private ListBranchResult filterForVisibility(
+      ProjectResource rsrc, List<Ref> refs, Set<String> targets) throws PermissionBackendException {
+    List<BranchInfo> branches = new ArrayList<>();
+    boolean hasMore = false;
+    int matchingRefs = 0;
     for (Ref ref : refs) {
-      if (ref.isSymbolic()) {
-        targets.add(ref.getTarget().getName());
+      Optional<BranchInfo> info =
+          toBranchInfo(ref, targets, rsrc.getNameKey(), rsrc.getProjectState(), rsrc.getUser());
+      if (info.isPresent()) {
+        matchingRefs += 1;
+        if (matchingRefs > start) {
+          branches.add(info.get());
+        }
+        if (limit > 0 && branches.size() == limit + 1) {
+          // Break and return earlier if we've already found 'limit' refs. The processing of the
+          // remaining refs for visibility is not needed anymore.
+          hasMore = true;
+          break;
+        }
       }
     }
+    if (hasMore && branches.size() >= 1) {
+      branches = branches.subList(0, branches.size() - 1);
+    }
+    return ListBranchResult.create(ImmutableList.copyOf(branches), hasMore);
+  }
 
-    PermissionBackend.ForProject perm = permissionBackend.currentUser().project(rsrc.getNameKey());
-    List<BranchInfo> branches = new ArrayList<>(refs.size());
-    for (Ref ref : refs) {
-      if (ref.isSymbolic()) {
-        // A symbolic reference to another branch, instead of
-        // showing the resolved value, show the name it references.
-        //
-        String target = ref.getTarget().getName();
+  /**
+   * Filter input list by seeking directly to the first item after the ref identified by {@link
+   * #nextPageToken}. As a precondition, the {@code inputRefs} should be sorted using {@link
+   * #REF_NAME_COMPARATOR}.
+   */
+  private ImmutableList<Ref> filterUsingNextPageToken(List<Ref> inputRefs)
+      throws BadRequestException {
+    try {
+      nextPageToken = decodeToken(nextPageToken);
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException("Invalid 'next-page-token'.", e);
+    }
+    List<String> refNames = inputRefs.stream().map(Ref::getName).collect(Collectors.toList());
+    // Seek to the next item after token
+    int nextItemIdx =
+        Arrays.binarySearch(
+            refNames.toArray(new String[refNames.size()]), nextPageToken, REF_NAME_COMPARATOR);
+    if (nextItemIdx == inputRefs.size()) {
+      return ImmutableList.of();
+    } else if (nextItemIdx < 0) {
+      // The item did not exist and binary search returned -(insertion point) - 1. Convert to
+      // the correct value.
+      nextItemIdx = -nextItemIdx - 1;
+    } else if (inputRefs.get(nextItemIdx).getName().equals(nextPageToken)) {
+      // Binary search returns the index of the element if it exists. If so, increase the index
+      // by 1 to point to the next element.
+      nextItemIdx += 1;
+    }
+    return inputRefs.subList(nextItemIdx, inputRefs.size()).stream()
+        .collect(ImmutableList.toImmutableList());
+  }
 
-        try {
-          perm.ref(target).check(RefPermission.READ);
-        } catch (AuthException e) {
-          continue;
-        }
-
-        if (target.startsWith(Constants.R_HEADS)) {
-          target = target.substring(Constants.R_HEADS.length());
-        }
-
-        BranchInfo b = new BranchInfo();
-        b.ref = ref.getName();
-        b.revision = target;
-        branches.add(b);
-
-        if (!Constants.HEAD.equals(ref.getName())) {
-          if (isConfigRef(ref.getName())) {
-            // Never allow to delete the meta config branch.
-            b.canDelete = null;
-          } else {
-            b.canDelete =
-                perm.ref(ref.getName()).testOrFalse(RefPermission.DELETE)
-                        && rsrc.getProjectState().statePermitsWrite()
-                    ? true
-                    : null;
-          }
-        }
-        continue;
-      }
+  /**
+   * Returns a {@link BranchInfo} if the branch is visible to the caller or {@link Optional#empty()}
+   * otherwise.
+   */
+  private Optional<BranchInfo> toBranchInfo(
+      Ref ref,
+      Set<String> targets,
+      Project.NameKey project,
+      ProjectState projectState,
+      CurrentUser currentUser)
+      throws PermissionBackendException {
+    PermissionBackend.ForProject perm = permissionBackend.currentUser().project(project);
+    if (ref.isSymbolic()) {
+      // A symbolic reference to another branch, instead of
+      // showing the resolved value, show the name it references.
+      //
+      String target = ref.getTarget().getName();
 
       try {
-        perm.ref(ref.getName()).check(RefPermission.READ);
-        branches.add(
-            createBranchInfo(
-                perm.ref(ref.getName()), ref, rsrc.getProjectState(), rsrc.getUser(), targets));
+        perm.ref(target).check(RefPermission.READ);
       } catch (AuthException e) {
-        // Do nothing.
+        return Optional.empty();
       }
+
+      if (target.startsWith(Constants.R_HEADS)) {
+        target = target.substring(Constants.R_HEADS.length());
+      }
+
+      BranchInfo info = new BranchInfo();
+      info.ref = ref.getName();
+      info.revision = target;
+      if (!Constants.HEAD.equals(ref.getName())) {
+        if (isConfigRef(ref.getName())) {
+          // Never allow to delete the meta config branch.
+          info.canDelete = null;
+        } else {
+          info.canDelete =
+              (perm.ref(ref.getName()).testOrFalse(RefPermission.DELETE)
+                      && projectState.statePermitsWrite())
+                  ? true
+                  : null;
+        }
+      }
+      return Optional.of(info);
     }
-    branches.sort(new BranchComparator());
-    return branches;
+
+    try {
+      perm.ref(ref.getName()).check(RefPermission.READ);
+      BranchInfo branchInfo =
+          createBranchInfo(perm.ref(ref.getName()), ref, projectState, currentUser, targets);
+      return Optional.of(branchInfo);
+    } catch (AuthException e) {
+      // Do nothing.
+      return Optional.empty();
+    }
   }
 
-  private static class BranchComparator implements Comparator<BranchInfo> {
+  private static Set<String> getTargets(List<Ref> refs) {
+    Set<String> targets = Sets.newHashSetWithExpectedSize(1);
+    refs.stream().filter(Ref::isSymbolic).forEach(r -> targets.add(r.getTarget().getName()));
+    return targets;
+  }
+
+  private static class RefComparator implements Comparator<Ref> {
     @Override
-    public int compare(BranchInfo a, BranchInfo b) {
+    public int compare(Ref a, Ref b) {
+      return REF_NAME_COMPARATOR.compare(a.getName(), b.getName());
+    }
+  }
+
+  private static class RefNameComparator implements Comparator<String> {
+    @Override
+    public int compare(String a, String b) {
       return ComparisonChain.start()
           .compareTrueFirst(isHead(a), isHead(b))
           .compareTrueFirst(isConfig(a), isConfig(b))
-          .compare(a.ref, b.ref)
+          .compare(a, b)
           .result();
     }
 
-    private static boolean isHead(BranchInfo i) {
-      return Constants.HEAD.equals(i.ref);
+    private static boolean isHead(String r) {
+      return Constants.HEAD.equals(r);
     }
 
-    private static boolean isConfig(BranchInfo i) {
-      return RefNames.REFS_CONFIG.equals(i.ref);
+    private static boolean isConfig(String r) {
+      return RefNames.REFS_CONFIG.equals(r);
     }
   }
 
@@ -269,9 +415,9 @@
       info.canDelete = null;
     } else {
       info.canDelete =
-          !targets.contains(ref.getName())
+          (!targets.contains(ref.getName())
                   && perm.testOrFalse(RefPermission.DELETE)
-                  && projectState.statePermitsWrite()
+                  && projectState.statePermitsWrite())
               ? true
               : null;
     }
@@ -289,4 +435,35 @@
     info.webLinks = links.isEmpty() ? null : links;
     return info;
   }
+
+  /** Encodes the {@link #nextPageToken} using proto serialization followed by based64 encoding. */
+  @VisibleForTesting
+  public static String encodeToken(String token) {
+    // The encoding of the header is prepended as a method to validate that this token was generated
+    // by the Gerrit server.
+    return ENCODED_HEADER + encodeImpl(token);
+  }
+
+  private static String encodeImpl(String token) {
+    return new String(
+        Base64.getEncoder()
+            .encode(
+                Protos.toByteArray(PaginationToken.newBuilder().setNextPageToken(token).build())),
+        StandardCharsets.UTF_8);
+  }
+
+  /** Validates that the token was encoded by the Gerrit server. */
+  private static boolean isValidToken(String token) {
+    return token.startsWith(ENCODED_HEADER);
+  }
+
+  /**
+   * Decodes the {@link #nextPageToken}. Callers should validate the token with the {@link
+   * #isValidToken(String)} method first.
+   */
+  private static String decodeToken(String encoded) {
+    encoded = encoded.substring(ENCODED_HEADER.length());
+    return Protos.parseUnchecked(PaginationToken.parser(), Base64.getDecoder().decode(encoded))
+        .getNextPageToken();
+  }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
index c6c6ddd..3ce8708 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -14,90 +14,25 @@
 
 package com.google.gerrit.server.restapi.project;
 
-import static com.google.common.base.Strings.emptyToNull;
-import static com.google.common.base.Strings.isNullOrEmpty;
-import static com.google.common.collect.Ordering.natural;
-import static com.google.gerrit.extensions.client.ProjectState.HIDDEN;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.Joiner;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSortedMap;
-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.GroupReference;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.exceptions.NoSuchGroupException;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.common.ProjectInfo;
-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.BinaryResult;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.json.OutputFormat;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.WebLinks;
-import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.group.GroupResolver;
-import com.google.gerrit.server.ioutil.RegexListSearcher;
-import com.google.gerrit.server.ioutil.StringUtil;
-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.permissions.RefPermission;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.util.TreeFormatter;
-import com.google.gson.reflect.TypeToken;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.BufferedWriter;
-import java.io.ByteArrayOutputStream;
 import java.io.IOException;
-import java.io.OutputStream;
-import java.io.OutputStreamWriter;
-import java.io.PrintWriter;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.Iterator;
-import java.util.LinkedHashMap;
-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.TreeMap;
-import java.util.TreeSet;
-import java.util.stream.Stream;
-import java.util.stream.StreamSupport;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
-import org.kohsuke.args4j.Option;
 
 /**
  * List projects visible to the calling user.
  *
  * <p>Implement {@code GET /projects/}, without a {@code query=} parameter.
  */
-public class ListProjects implements RestReadView<TopLevelResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
+public interface ListProjects extends RestReadView<TopLevelResource> {
   public enum FilterType {
     CODE {
       @Override
@@ -141,603 +76,36 @@
     abstract boolean useMatch();
   }
 
-  private final CurrentUser currentUser;
-  private final ProjectCache projectCache;
-  private final GroupResolver groupResolver;
-  private final GroupControl.Factory groupControlFactory;
-  private final GitRepositoryManager repoManager;
-  private final PermissionBackend permissionBackend;
-  private final ProjectNode.Factory projectNodeFactory;
-  private final WebLinks webLinks;
+  void setFormat(OutputFormat fmt);
 
-  @Deprecated
-  @Option(name = "--format", usage = "(deprecated) output format")
-  private OutputFormat format = OutputFormat.TEXT;
+  void addShowBranch(String branch);
 
-  @Option(
-      name = "--show-branch",
-      aliases = {"-b"},
-      usage = "displays the sha of each project in the specified branch")
-  public void addShowBranch(String branch) {
-    showBranch.add(branch);
-  }
+  void setShowTree(boolean showTree);
 
-  @Option(
-      name = "--tree",
-      aliases = {"-t"},
-      usage =
-          "displays project inheritance in a tree-like format\n"
-              + "this option does not work together with the show-branch option")
-  public void setShowTree(boolean showTree) {
-    this.showTree = showTree;
-  }
+  void setFilterType(FilterType type);
 
-  @Option(name = "--type", usage = "type of project")
-  public void setFilterType(FilterType type) {
-    this.type = type;
-  }
+  void setShowDescription(boolean showDescription);
 
-  @Option(
-      name = "--description",
-      aliases = {"-d"},
-      usage = "include description of project in list")
-  public void setShowDescription(boolean showDescription) {
-    this.showDescription = showDescription;
-  }
+  void setAll(boolean all);
 
-  @Option(name = "--all", usage = "display all projects that are accessible by the calling user")
-  public void setAll(boolean all) {
-    this.all = all;
-  }
+  void setState(com.google.gerrit.extensions.client.ProjectState state);
 
-  @Option(
-      name = "--state",
-      aliases = {"-s"},
-      usage = "filter by project state")
-  public void setState(com.google.gerrit.extensions.client.ProjectState state) {
-    this.state = state;
-  }
+  void setLimit(int limit);
 
-  @Option(
-      name = "--limit",
-      aliases = {"-n"},
-      metaVar = "CNT",
-      usage = "maximum number of projects to list")
-  public void setLimit(int limit) {
-    this.limit = limit;
-  }
+  void setStart(int start);
 
-  @Option(
-      name = "--start",
-      aliases = {"-S"},
-      metaVar = "CNT",
-      usage = "number of projects to skip")
-  public void setStart(int start) {
-    this.start = start;
-  }
+  void setMatchPrefix(String matchPrefix);
 
-  @Option(
-      name = "--prefix",
-      aliases = {"-p"},
-      metaVar = "PREFIX",
-      usage = "match project prefix")
-  public void setMatchPrefix(String matchPrefix) {
-    this.matchPrefix = matchPrefix;
-  }
+  void setMatchSubstring(String matchSubstring);
 
-  @Option(
-      name = "--match",
-      aliases = {"-m"},
-      metaVar = "MATCH",
-      usage = "match project substring")
-  public void setMatchSubstring(String matchSubstring) {
-    this.matchSubstring = matchSubstring;
-  }
+  void setMatchRegex(String matchRegex);
 
-  @Option(name = "-r", metaVar = "REGEX", usage = "match project regex")
-  public void setMatchRegex(String matchRegex) {
-    this.matchRegex = matchRegex;
-  }
-
-  @Option(
-      name = "--has-acl-for",
-      metaVar = "GROUP",
-      usage = "displays only projects on which access rights for this group are directly assigned")
-  public void setGroupUuid(AccountGroup.UUID groupUuid) {
-    this.groupUuid = groupUuid;
-  }
-
-  private final List<String> showBranch = new ArrayList<>();
-  private boolean showTree;
-  private FilterType type = FilterType.ALL;
-  private boolean showDescription;
-  private boolean all;
-  private com.google.gerrit.extensions.client.ProjectState state;
-  private int limit;
-  private int start;
-  private String matchPrefix;
-  private String matchSubstring;
-  private String matchRegex;
-  private AccountGroup.UUID groupUuid;
-  private final Provider<QueryProjects> queryProjectsProvider;
-  private final boolean listProjectsFromIndex;
-
-  @Inject
-  protected ListProjects(
-      CurrentUser currentUser,
-      ProjectCache projectCache,
-      GroupResolver groupResolver,
-      GroupControl.Factory groupControlFactory,
-      GitRepositoryManager repoManager,
-      PermissionBackend permissionBackend,
-      ProjectNode.Factory projectNodeFactory,
-      WebLinks webLinks,
-      Provider<QueryProjects> queryProjectsProvider,
-      @GerritServerConfig Config config) {
-    this.currentUser = currentUser;
-    this.projectCache = projectCache;
-    this.groupResolver = groupResolver;
-    this.groupControlFactory = groupControlFactory;
-    this.repoManager = repoManager;
-    this.permissionBackend = permissionBackend;
-    this.projectNodeFactory = projectNodeFactory;
-    this.webLinks = webLinks;
-    this.queryProjectsProvider = queryProjectsProvider;
-    this.listProjectsFromIndex = config.getBoolean("gerrit", "listProjectsFromIndex", false);
-  }
-
-  public List<String> getShowBranch() {
-    return showBranch;
-  }
-
-  public boolean isShowTree() {
-    return showTree;
-  }
-
-  public boolean isShowDescription() {
-    return showDescription;
-  }
-
-  public OutputFormat getFormat() {
-    return format;
-  }
-
-  public ListProjects setFormat(OutputFormat fmt) {
-    format = fmt;
-    return this;
-  }
+  void setGroupUuid(AccountGroup.UUID groupUuid);
 
   @Override
-  public Response<Object> apply(TopLevelResource resource)
-      throws BadRequestException, PermissionBackendException {
-    if (format == OutputFormat.TEXT) {
-      ByteArrayOutputStream buf = new ByteArrayOutputStream();
-      displayToStream(buf);
-      return Response.ok(
-          BinaryResult.create(buf.toByteArray())
-              .setContentType("text/plain")
-              .setCharacterEncoding(UTF_8));
-    }
+  default Response<Object> apply(TopLevelResource resource) throws Exception {
     return Response.ok(apply());
   }
 
-  public SortedMap<String, ProjectInfo> apply()
-      throws BadRequestException, PermissionBackendException {
-    Optional<String> projectQuery = expressAsProjectsQuery();
-    if (projectQuery.isPresent()) {
-      return applyAsQuery(projectQuery.get());
-    }
-
-    format = OutputFormat.JSON;
-    return display(null);
-  }
-
-  private Optional<String> expressAsProjectsQuery() {
-    return listProjectsFromIndex
-            && !all
-            && state != HIDDEN
-            && isNullOrEmpty(matchPrefix)
-            && isNullOrEmpty(matchRegex)
-            && isNullOrEmpty(
-                matchSubstring) // TODO: see https://issues.gerritcodereview.com/issues/40010295
-            && type == FilterType.ALL
-            && showBranch.isEmpty()
-            && !showTree
-        ? Optional.of(stateToQuery())
-        : Optional.empty();
-  }
-
-  private String stateToQuery() {
-    List<String> queries = new ArrayList<>();
-    if (state == null) {
-      queries.add("(state:active OR state:read-only)");
-    } else {
-      queries.add(String.format("(state:%s)", state.name()));
-    }
-
-    return Joiner.on(" AND ").join(queries);
-  }
-
-  private SortedMap<String, ProjectInfo> applyAsQuery(String query) throws BadRequestException {
-    try {
-      return queryProjectsProvider
-          .get()
-          .withQuery(query)
-          .withStart(start)
-          .withLimit(limit)
-          .apply()
-          .stream()
-          .collect(
-              ImmutableSortedMap.toImmutableSortedMap(
-                  natural(), p -> p.name, p -> showDescription ? p : nullifyDescription(p)));
-    } catch (StorageException | MethodNotAllowedException e) {
-      logger.atWarning().withCause(e).log(
-          "Internal error while processing the query '%s' request", query);
-      throw new BadRequestException("Internal error while processing the query request");
-    }
-  }
-
-  private ProjectInfo nullifyDescription(ProjectInfo p) {
-    p.description = null;
-    return p;
-  }
-
-  private void printQueryResults(String query, PrintWriter out) throws BadRequestException {
-    try {
-      if (format.isJson()) {
-        format.newGson().toJson(applyAsQuery(query), out);
-      } else {
-        newProjectsNamesStream(query).forEach(out::println);
-      }
-      out.flush();
-    } catch (StorageException | MethodNotAllowedException e) {
-      logger.atWarning().withCause(e).log(
-          "Internal error while processing the query '%s' request", query);
-      throw new BadRequestException("Internal error while processing the query request");
-    }
-  }
-
-  private Stream<String> newProjectsNamesStream(String query)
-      throws MethodNotAllowedException, BadRequestException {
-    Stream<String> projects =
-        queryProjectsProvider.get().withQuery(query).apply().stream().map(p -> p.name).skip(start);
-    if (limit > 0) {
-      projects = projects.limit(limit);
-    }
-
-    return projects;
-  }
-
-  public void displayToStream(OutputStream displayOutputStream)
-      throws BadRequestException, PermissionBackendException {
-    PrintWriter stdout =
-        new PrintWriter(new BufferedWriter(new OutputStreamWriter(displayOutputStream, UTF_8)));
-    Optional<String> projectsQuery = expressAsProjectsQuery();
-
-    if (projectsQuery.isPresent()) {
-      printQueryResults(projectsQuery.get(), stdout);
-    } else {
-      display(stdout);
-    }
-  }
-
-  @Nullable
-  public SortedMap<String, ProjectInfo> display(@Nullable PrintWriter stdout)
-      throws BadRequestException, PermissionBackendException {
-    if (all && state != null) {
-      throw new BadRequestException("'all' and 'state' may not be used together");
-    }
-    if (!isGroupVisible()) {
-      return Collections.emptySortedMap();
-    }
-
-    int foundIndex = 0;
-    int found = 0;
-    TreeMap<String, ProjectInfo> output = new TreeMap<>();
-    Map<String, String> hiddenNames = new HashMap<>();
-    Map<Project.NameKey, Boolean> accessibleParents = new HashMap<>();
-    PermissionBackend.WithUser perm = permissionBackend.user(currentUser);
-    final TreeMap<Project.NameKey, ProjectNode> treeMap = new TreeMap<>();
-    try {
-      Iterator<ProjectState> projectStatesIt = filter(perm).iterator();
-      while (projectStatesIt.hasNext()) {
-        ProjectState e = projectStatesIt.next();
-        Project.NameKey projectName = e.getNameKey();
-        if (e.getProject().getState() == HIDDEN && !all && state != HIDDEN) {
-          // If we can't get it from the cache, pretend it's not present.
-          // If all wasn't selected, and it's HIDDEN, pretend it's not present.
-          // If state HIDDEN wasn't selected, and it's HIDDEN, pretend it's not present.
-          continue;
-        }
-
-        if (state != null && e.getProject().getState() != state) {
-          continue;
-        }
-
-        if (groupUuid != null
-            && !e.getLocalGroups()
-                .contains(GroupReference.forGroup(groupResolver.parseId(groupUuid.get())))) {
-          continue;
-        }
-
-        if (showTree && !format.isJson()) {
-          treeMap.put(projectName, projectNodeFactory.create(e.getProject(), true));
-          continue;
-        }
-
-        if (foundIndex++ < start) {
-          continue;
-        }
-        if (limit > 0 && ++found > limit) {
-          break;
-        }
-
-        ProjectInfo info = new ProjectInfo();
-        info.name = projectName.get();
-        if (showTree && format.isJson()) {
-          addParentProjectInfo(hiddenNames, accessibleParents, perm, e, info);
-        }
-
-        if (showDescription) {
-          info.description = emptyToNull(e.getProject().getDescription());
-        }
-        info.state = e.getProject().getState();
-
-        try {
-          if (!showBranch.isEmpty()) {
-            try (Repository git = repoManager.openRepository(projectName)) {
-              if (!type.matches(git)) {
-                continue;
-              }
-
-              List<Ref> refs = retrieveBranchRefs(e, git);
-              if (!hasValidRef(refs)) {
-                continue;
-              }
-
-              addProjectBranchesInfo(info, refs);
-            }
-          } else if (!showTree && type.useMatch()) {
-            try (Repository git = repoManager.openRepository(projectName)) {
-              if (!type.matches(git)) {
-                continue;
-              }
-            }
-          }
-        } catch (RepositoryNotFoundException err) {
-          // If the Git repository is gone, the project doesn't actually exist anymore.
-          continue;
-        } catch (IOException err) {
-          logger.atWarning().withCause(err).log("Unexpected error reading %s", projectName);
-          continue;
-        }
-
-        ImmutableList<WebLinkInfo> links = webLinks.getProjectLinks(projectName.get());
-        info.webLinks = links.isEmpty() ? null : links;
-
-        if (stdout == null || format.isJson()) {
-          output.put(info.name, info);
-          continue;
-        }
-
-        if (!showBranch.isEmpty()) {
-          printProjectBranches(stdout, info);
-        }
-        stdout.print(info.name);
-
-        if (info.description != null) {
-          // We still want to list every project as one-liners, hence escaping \n.
-          stdout.print(" - " + StringUtil.escapeString(info.description));
-        }
-        stdout.print('\n');
-      }
-
-      for (ProjectInfo info : output.values()) {
-        info.id = Url.encode(info.name);
-        info.name = null;
-      }
-      if (stdout == null) {
-        return output;
-      } else if (format.isJson()) {
-        format
-            .newGson()
-            .toJson(output, new TypeToken<Map<String, ProjectInfo>>() {}.getType(), stdout);
-        stdout.print('\n');
-      } else if (showTree && treeMap.size() > 0) {
-        printProjectTree(stdout, treeMap);
-      }
-      return null;
-    } finally {
-      if (stdout != null) {
-        stdout.flush();
-      }
-    }
-  }
-
-  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;
-      if (ref == null) {
-        // Print stub (forty '-' symbols)
-        ref = "----------------------------------------";
-      }
-      stdout.print(ref);
-      stdout.print(' ');
-    }
-  }
-
-  private void addProjectBranchesInfo(ProjectInfo info, List<Ref> refs) {
-    for (int i = 0; i < showBranch.size(); i++) {
-      Ref ref = refs.get(i);
-      if (ref != null && ref.getObjectId() != null) {
-        if (info.branches == null) {
-          info.branches = new LinkedHashMap<>();
-        }
-        info.branches.put(showBranch.get(i), ref.getObjectId().name());
-      }
-    }
-  }
-
-  private List<Ref> retrieveBranchRefs(ProjectState e, Repository git) {
-    if (!e.statePermitsRead()) {
-      return ImmutableList.of();
-    }
-
-    return getBranchRefs(e.getNameKey(), git);
-  }
-
-  private void addParentProjectInfo(
-      Map<String, String> hiddenNames,
-      Map<Project.NameKey, Boolean> accessibleParents,
-      PermissionBackend.WithUser perm,
-      ProjectState e,
-      ProjectInfo info)
-      throws PermissionBackendException {
-    ProjectState parent = Iterables.getFirst(e.parents(), null);
-    if (parent != null) {
-      if (isParentAccessible(accessibleParents, perm, parent)) {
-        info.parent = parent.getName();
-      } else {
-        info.parent = hiddenNames.get(parent.getName());
-        if (info.parent == null) {
-          info.parent = "?-" + (hiddenNames.size() + 1);
-          hiddenNames.put(parent.getName(), info.parent);
-        }
-      }
-    }
-  }
-
-  private Stream<ProjectState> filter(PermissionBackend.WithUser perm) throws BadRequestException {
-    return StreamSupport.stream(scan().spliterator(), false)
-        .map(projectCache::get)
-        .filter(Optional::isPresent)
-        .map(Optional::get)
-        .filter(p -> permissionCheck(p, perm));
-  }
-
-  private boolean permissionCheck(ProjectState state, PermissionBackend.WithUser perm) {
-    // Hidden projects(permitsRead = false) should only be accessible by the project owners.
-    // READ_CONFIG is checked here because it's only allowed to project owners(ACCESS may also
-    // be allowed for other users). Allowing project owners to access here will help them to view
-    // and update the config of hidden projects easily.
-    return perm.project(state.getNameKey())
-        .testOrFalse(
-            state.statePermitsRead() ? ProjectPermission.ACCESS : ProjectPermission.READ_CONFIG);
-  }
-
-  private boolean isParentAccessible(
-      Map<Project.NameKey, Boolean> checked, PermissionBackend.WithUser perm, ProjectState state)
-      throws PermissionBackendException {
-    Project.NameKey name = state.getNameKey();
-    Boolean b = checked.get(name);
-    if (b == null) {
-      try {
-        // Hidden projects(permitsRead = false) should only be accessible by the project owners.
-        // READ_CONFIG is checked here because it's only allowed to project owners(ACCESS may also
-        // be allowed for other users). Allowing project owners to access here will help them to
-        // view
-        // and update the config of hidden projects easily.
-        ProjectPermission permissionToCheck =
-            state.statePermitsRead() ? ProjectPermission.ACCESS : ProjectPermission.READ_CONFIG;
-        perm.project(name).check(permissionToCheck);
-        b = true;
-      } catch (AuthException denied) {
-        b = false;
-      }
-      checked.put(name, b);
-    }
-    return b;
-  }
-
-  private Stream<Project.NameKey> scan() throws BadRequestException {
-    if (matchPrefix != null) {
-      checkMatchOptions(matchSubstring == null && matchRegex == null);
-      return projectCache.byName(matchPrefix).stream();
-    } else if (matchSubstring != null) {
-      checkMatchOptions(matchPrefix == null && matchRegex == null);
-      return projectCache.all().stream()
-          .filter(
-              p -> p.get().toLowerCase(Locale.US).contains(matchSubstring.toLowerCase(Locale.US)));
-    } else if (matchRegex != null) {
-      checkMatchOptions(matchPrefix == null && matchSubstring == null);
-      RegexListSearcher<Project.NameKey> searcher;
-      try {
-        searcher = new RegexListSearcher<>(matchRegex, Project.NameKey::get);
-      } catch (IllegalArgumentException e) {
-        throw new BadRequestException(e.getMessage());
-      }
-      return searcher.search(projectCache.all().asList());
-    } else {
-      return projectCache.all().stream();
-    }
-  }
-
-  private static void checkMatchOptions(boolean cond) throws BadRequestException {
-    if (!cond) {
-      throw new BadRequestException("specify exactly one of p/m/r");
-    }
-  }
-
-  private void printProjectTree(
-      final PrintWriter stdout, TreeMap<Project.NameKey, ProjectNode> treeMap) {
-    final NavigableSet<ProjectNode> sortedNodes = new TreeSet<>();
-
-    // Builds the inheritance tree using a list.
-    //
-    for (ProjectNode key : treeMap.values()) {
-      if (key.isAllProjects()) {
-        sortedNodes.add(key);
-        continue;
-      }
-
-      ProjectNode node = treeMap.get(key.getParentName());
-      if (node != null) {
-        node.addChild(key);
-      } else {
-        sortedNodes.add(key);
-      }
-    }
-
-    final TreeFormatter treeFormatter = new TreeFormatter(stdout);
-    treeFormatter.printTree(sortedNodes);
-    stdout.flush();
-  }
-
-  private List<Ref> getBranchRefs(Project.NameKey projectName, Repository git) {
-    Ref[] result = new Ref[showBranch.size()];
-    try {
-      PermissionBackend.ForProject perm = permissionBackend.user(currentUser).project(projectName);
-      for (int i = 0; i < showBranch.size(); i++) {
-        Ref ref = git.findRef(showBranch.get(i));
-        if (ref != null && ref.getObjectId() != null) {
-          try {
-            perm.ref(ref.getLeaf().getName()).check(RefPermission.READ);
-            result[i] = ref;
-          } catch (AuthException e) {
-            continue;
-          }
-        }
-      }
-    } catch (IOException | PermissionBackendException e) {
-      // Fall through and return what is available.
-    }
-    return Arrays.asList(result);
-  }
-
-  private static boolean hasValidRef(List<Ref> refs) {
-    for (Ref ref : refs) {
-      if (ref != null) {
-        return true;
-      }
-    }
-    return false;
-  }
+  SortedMap<String, ProjectInfo> apply() throws Exception;
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjectsImpl.java b/java/com/google/gerrit/server/restapi/project/ListProjectsImpl.java
new file mode 100644
index 0000000..8b386e2
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/ListProjectsImpl.java
@@ -0,0 +1,693 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.project;
+
+import static com.google.common.base.Strings.emptyToNull;
+import static com.google.common.base.Strings.isNullOrEmpty;
+import static com.google.common.collect.Ordering.natural;
+import static com.google.gerrit.extensions.client.ProjectState.HIDDEN;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSortedMap;
+import com.google.common.collect.Iterables;
+import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.NoSuchGroupException;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.common.ProjectInfo;
+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.BinaryResult;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.index.project.ProjectField;
+import com.google.gerrit.index.project.ProjectIndexCollection;
+import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.WebLinks;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.group.GroupResolver;
+import com.google.gerrit.server.ioutil.RegexListSearcher;
+import com.google.gerrit.server.ioutil.StringUtil;
+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.permissions.RefPermission;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.util.TreeFormatter;
+import com.google.gson.reflect.TypeToken;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.BufferedWriter;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+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.TreeMap;
+import java.util.TreeSet;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * List projects visible to the calling user.
+ *
+ * <p>Implement {@code GET /projects/}, without a {@code query=} parameter.
+ */
+public class ListProjectsImpl extends AbstractListProjects {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final CurrentUser currentUser;
+  private final ProjectCache projectCache;
+  private final GroupResolver groupResolver;
+  private final GroupControl.Factory groupControlFactory;
+  private final GitRepositoryManager repoManager;
+  private final PermissionBackend permissionBackend;
+  private final ProjectNode.Factory projectNodeFactory;
+  private final WebLinks webLinks;
+
+  @Override
+  public void setFormat(OutputFormat fmt) {
+    format = fmt;
+  }
+
+  @Override
+  public void addShowBranch(String branch) {
+    showBranch.add(branch);
+  }
+
+  @Override
+  public void setShowTree(boolean showTree) {
+    this.showTree = showTree;
+  }
+
+  @Override
+  public void setFilterType(FilterType type) {
+    this.type = type;
+  }
+
+  @Override
+  public void setShowDescription(boolean showDescription) {
+    this.showDescription = showDescription;
+  }
+
+  @Override
+  public void setAll(boolean all) {
+    this.all = all;
+  }
+
+  @Override
+  public void setState(com.google.gerrit.extensions.client.ProjectState state) {
+    this.state = state;
+  }
+
+  @Override
+  public void setLimit(int limit) {
+    this.limit = limit;
+  }
+
+  @Override
+  public void setStart(int start) {
+    this.start = start;
+  }
+
+  @Override
+  public void setMatchPrefix(String matchPrefix) {
+    this.matchPrefix = matchPrefix;
+  }
+
+  @Override
+  public void setMatchSubstring(String matchSubstring) {
+    this.matchSubstring = matchSubstring;
+  }
+
+  @Override
+  public void setMatchRegex(String matchRegex) {
+    this.matchRegex = matchRegex;
+  }
+
+  @Override
+  public void setGroupUuid(AccountGroup.UUID groupUuid) {
+    this.groupUuid = groupUuid;
+  }
+
+  @Deprecated private OutputFormat format = OutputFormat.TEXT;
+  private final List<String> showBranch = new ArrayList<>();
+  private boolean showTree;
+  private FilterType type = FilterType.ALL;
+  private boolean showDescription;
+  private boolean all;
+  private com.google.gerrit.extensions.client.ProjectState state;
+  private int limit;
+  private int start;
+  private String matchPrefix;
+  private String matchSubstring;
+  private String matchRegex;
+  private AccountGroup.UUID groupUuid;
+  private final Provider<QueryProjects> queryProjectsProvider;
+  private final boolean listProjectsFromIndex;
+  private final ProjectIndexCollection projectIndexes;
+
+  @Inject
+  protected ListProjectsImpl(
+      CurrentUser currentUser,
+      ProjectCache projectCache,
+      GroupResolver groupResolver,
+      GroupControl.Factory groupControlFactory,
+      GitRepositoryManager repoManager,
+      PermissionBackend permissionBackend,
+      ProjectNode.Factory projectNodeFactory,
+      WebLinks webLinks,
+      Provider<QueryProjects> queryProjectsProvider,
+      @GerritServerConfig Config config,
+      ProjectIndexCollection projectIndexes) {
+    this.currentUser = currentUser;
+    this.projectCache = projectCache;
+    this.groupResolver = groupResolver;
+    this.groupControlFactory = groupControlFactory;
+    this.repoManager = repoManager;
+    this.permissionBackend = permissionBackend;
+    this.projectNodeFactory = projectNodeFactory;
+    this.webLinks = webLinks;
+    this.queryProjectsProvider = queryProjectsProvider;
+    this.listProjectsFromIndex = config.getBoolean("gerrit", "listProjectsFromIndex", false);
+    this.projectIndexes = projectIndexes;
+  }
+
+  public List<String> getShowBranch() {
+    return showBranch;
+  }
+
+  public boolean isShowTree() {
+    return showTree;
+  }
+
+  public boolean isShowDescription() {
+    return showDescription;
+  }
+
+  public OutputFormat getFormat() {
+    return format;
+  }
+
+  @Override
+  public Response<Object> apply(TopLevelResource resource)
+      throws BadRequestException, PermissionBackendException {
+    if (format == OutputFormat.TEXT) {
+      ByteArrayOutputStream buf = new ByteArrayOutputStream();
+      displayToStream(buf);
+      return Response.ok(
+          BinaryResult.create(buf.toByteArray())
+              .setContentType("text/plain")
+              .setCharacterEncoding(UTF_8));
+    }
+    return Response.ok(apply());
+  }
+
+  @Override
+  public SortedMap<String, ProjectInfo> apply()
+      throws BadRequestException, PermissionBackendException {
+    Optional<String> projectQuery = expressAsProjectsQuery();
+    if (projectQuery.isPresent()) {
+      return applyAsQuery(projectQuery.get());
+    }
+
+    format = OutputFormat.JSON;
+    return display(null);
+  }
+
+  private Optional<String> expressAsProjectsQuery() throws BadRequestException {
+    return listProjectsFromIndex
+            && !all
+            && state != HIDDEN
+            && (isNullOrEmpty(matchPrefix)
+                || projectIndexes
+                    .getSearchIndex()
+                    .getSchema()
+                    .hasField(ProjectField.PREFIX_NAME_SPEC))
+            && isNullOrEmpty(matchRegex)
+            && isNullOrEmpty(
+                matchSubstring) // TODO: see https://issues.gerritcodereview.com/issues/40010295
+            && type == FilterType.ALL
+            && showBranch.isEmpty()
+            && !showTree
+        ? Optional.of(toQuery())
+        : Optional.empty();
+  }
+
+  private String toQuery() throws BadRequestException {
+    // QueryProjects supports specifying matchPrefix and matchSubstring at the same time, but to
+    // keep the behavior consistent regardless of whether 'gerrit.listProjectsFromIndex' is true or
+    // false, disallow specifying both at the same time here. This way
+    // 'gerrit.listProjectsFromIndex' can be troggled without breaking any caller.
+    if (matchPrefix != null) {
+      checkMatchOptions(matchSubstring == null);
+    } else if (matchSubstring != null) {
+      checkMatchOptions(matchPrefix == null);
+    }
+
+    List<String> queries = new ArrayList<>();
+
+    if (state != null) {
+      queries.add(String.format("(state:%s)", state.name()));
+    }
+    if (!isNullOrEmpty(matchPrefix)) {
+      queries.add(String.format("prefix:%s", matchPrefix));
+    }
+    if (!isNullOrEmpty(matchSubstring)) {
+      queries.add(String.format("substring:%s", matchSubstring));
+    }
+
+    return queries.isEmpty() ? "" : Joiner.on(" AND ").join(queries);
+  }
+
+  private SortedMap<String, ProjectInfo> applyAsQuery(String query) throws BadRequestException {
+    try {
+      return queryProjectsProvider
+          .get()
+          .withQuery(query)
+          .withStart(start)
+          .withLimit(limit)
+          .apply()
+          .stream()
+          .collect(
+              ImmutableSortedMap.toImmutableSortedMap(
+                  natural(), p -> p.name, p -> showDescription ? p : nullifyDescription(p)));
+    } catch (StorageException | MethodNotAllowedException e) {
+      logger.atWarning().withCause(e).log(
+          "Internal error while processing the query '%s' request", query);
+      throw new BadRequestException("Internal error while processing the query request");
+    }
+  }
+
+  private ProjectInfo nullifyDescription(ProjectInfo p) {
+    p.description = null;
+    return p;
+  }
+
+  private void printQueryResults(String query, PrintWriter out) throws BadRequestException {
+    try {
+      if (format.isJson()) {
+        format.newGson().toJson(applyAsQuery(query), out);
+      } else {
+        newProjectsNamesStream(query).forEach(out::println);
+      }
+      out.flush();
+    } catch (StorageException | MethodNotAllowedException e) {
+      logger.atWarning().withCause(e).log(
+          "Internal error while processing the query '%s' request", query);
+      throw new BadRequestException("Internal error while processing the query request");
+    }
+  }
+
+  private Stream<String> newProjectsNamesStream(String query)
+      throws MethodNotAllowedException, BadRequestException {
+    Stream<String> projects =
+        queryProjectsProvider.get().withQuery(query).apply().stream().map(p -> p.name).skip(start);
+    if (limit > 0) {
+      projects = projects.limit(limit);
+    }
+
+    return projects;
+  }
+
+  public void displayToStream(OutputStream displayOutputStream)
+      throws BadRequestException, PermissionBackendException {
+    PrintWriter stdout =
+        new PrintWriter(new BufferedWriter(new OutputStreamWriter(displayOutputStream, UTF_8)));
+    Optional<String> projectsQuery = expressAsProjectsQuery();
+
+    if (projectsQuery.isPresent()) {
+      printQueryResults(projectsQuery.get(), stdout);
+    } else {
+      display(stdout);
+    }
+  }
+
+  @CanIgnoreReturnValue
+  @Nullable
+  public SortedMap<String, ProjectInfo> display(@Nullable PrintWriter stdout)
+      throws BadRequestException, PermissionBackendException {
+    if (all && state != null) {
+      throw new BadRequestException("'all' and 'state' may not be used together");
+    }
+    if (!isGroupVisible()) {
+      return Collections.emptySortedMap();
+    }
+
+    int foundIndex = 0;
+    int found = 0;
+    TreeMap<String, ProjectInfo> output = new TreeMap<>();
+    Map<String, String> hiddenNames = new HashMap<>();
+    Map<Project.NameKey, Boolean> accessibleParents = new HashMap<>();
+    PermissionBackend.WithUser perm = permissionBackend.user(currentUser);
+    final TreeMap<Project.NameKey, ProjectNode> treeMap = new TreeMap<>();
+    ProjectInfo lastInfo = null;
+    try {
+      Iterator<ProjectState> projectStatesIt = filter(perm).iterator();
+      while (projectStatesIt.hasNext()) {
+        ProjectState e = projectStatesIt.next();
+        Project.NameKey projectName = e.getNameKey();
+        if (e.getProject().getState() == HIDDEN && !all && state != HIDDEN) {
+          // If we can't get it from the cache, pretend it's not present.
+          // If all wasn't selected, and it's HIDDEN, pretend it's not present.
+          // If state HIDDEN wasn't selected, and it's HIDDEN, pretend it's not present.
+          continue;
+        }
+
+        if (state != null && e.getProject().getState() != state) {
+          continue;
+        }
+
+        if (groupUuid != null
+            && !e.getLocalGroups()
+                .contains(GroupReference.forGroup(groupResolver.parseId(groupUuid.get())))) {
+          continue;
+        }
+
+        if (showTree && !format.isJson()) {
+          treeMap.put(projectName, projectNodeFactory.create(e.getProject(), true));
+          continue;
+        }
+
+        if (foundIndex++ < start) {
+          continue;
+        }
+        if (limit > 0 && ++found > limit) {
+          if (lastInfo != null) {
+            lastInfo._moreProjects = true;
+          }
+          break;
+        }
+
+        ProjectInfo info = new ProjectInfo();
+        lastInfo = info;
+
+        info.name = projectName.get();
+        if (showTree && format.isJson()) {
+          addParentProjectInfo(hiddenNames, accessibleParents, perm, e, info);
+        }
+
+        if (showDescription) {
+          info.description = emptyToNull(e.getProject().getDescription());
+        }
+        info.state = e.getProject().getState();
+
+        try {
+          if (!showBranch.isEmpty()) {
+            try (Repository git = repoManager.openRepository(projectName)) {
+              if (!type.matches(git)) {
+                continue;
+              }
+
+              List<Ref> refs = retrieveBranchRefs(e, git);
+              if (!hasValidRef(refs)) {
+                continue;
+              }
+
+              addProjectBranchesInfo(info, refs);
+            }
+          } else if (!showTree && type.useMatch()) {
+            try (Repository git = repoManager.openRepository(projectName)) {
+              if (!type.matches(git)) {
+                continue;
+              }
+            }
+          }
+        } catch (RepositoryNotFoundException err) {
+          // If the Git repository is gone, the project doesn't actually exist anymore.
+          continue;
+        } catch (IOException err) {
+          logger.atWarning().withCause(err).log("Unexpected error reading %s", projectName);
+          continue;
+        }
+
+        ImmutableList<WebLinkInfo> links = webLinks.getProjectLinks(projectName.get());
+        info.webLinks = links.isEmpty() ? null : links;
+
+        if (stdout == null || format.isJson()) {
+          output.put(info.name, info);
+          continue;
+        }
+
+        if (!showBranch.isEmpty()) {
+          printProjectBranches(stdout, info);
+        }
+        stdout.print(info.name);
+
+        if (info.description != null) {
+          // We still want to list every project as one-liners, hence escaping \n.
+          stdout.print(" - " + StringUtil.escapeString(info.description));
+        }
+        stdout.print('\n');
+      }
+
+      for (ProjectInfo info : output.values()) {
+        info.id = Url.encode(info.name);
+        info.name = null;
+      }
+      if (stdout == null) {
+        return output;
+      } else if (format.isJson()) {
+        format
+            .newGson()
+            .toJson(output, new TypeToken<Map<String, ProjectInfo>>() {}.getType(), stdout);
+        stdout.print('\n');
+      } else if (showTree && treeMap.size() > 0) {
+        printProjectTree(stdout, treeMap);
+      }
+      return null;
+    } finally {
+      if (stdout != null) {
+        stdout.flush();
+      }
+    }
+  }
+
+  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;
+      if (ref == null) {
+        // Print stub (forty '-' symbols)
+        ref = "----------------------------------------";
+      }
+      stdout.print(ref);
+      stdout.print(' ');
+    }
+  }
+
+  private void addProjectBranchesInfo(ProjectInfo info, List<Ref> refs) {
+    for (int i = 0; i < showBranch.size(); i++) {
+      Ref ref = refs.get(i);
+      if (ref != null && ref.getObjectId() != null) {
+        if (info.branches == null) {
+          info.branches = new LinkedHashMap<>();
+        }
+        info.branches.put(showBranch.get(i), ref.getObjectId().name());
+      }
+    }
+  }
+
+  private List<Ref> retrieveBranchRefs(ProjectState e, Repository git) {
+    if (!e.statePermitsRead()) {
+      return ImmutableList.of();
+    }
+
+    return getBranchRefs(e.getNameKey(), git);
+  }
+
+  private void addParentProjectInfo(
+      Map<String, String> hiddenNames,
+      Map<Project.NameKey, Boolean> accessibleParents,
+      PermissionBackend.WithUser perm,
+      ProjectState e,
+      ProjectInfo info)
+      throws PermissionBackendException {
+    ProjectState parent = Iterables.getFirst(e.parents(), null);
+    if (parent != null) {
+      if (isParentAccessible(accessibleParents, perm, parent)) {
+        info.parent = parent.getName();
+      } else {
+        info.parent = hiddenNames.get(parent.getName());
+        if (info.parent == null) {
+          info.parent = "?-" + (hiddenNames.size() + 1);
+          hiddenNames.put(parent.getName(), info.parent);
+        }
+      }
+    }
+  }
+
+  private Stream<ProjectState> filter(PermissionBackend.WithUser perm) throws BadRequestException {
+    return StreamSupport.stream(scan().spliterator(), false)
+        .map(projectCache::get)
+        .filter(Optional::isPresent)
+        .map(Optional::get)
+        .filter(p -> permissionCheck(p, perm));
+  }
+
+  private boolean permissionCheck(ProjectState state, PermissionBackend.WithUser perm) {
+    // Hidden projects(permitsRead = false) should only be accessible by the project owners.
+    // READ_CONFIG is checked here because it's only allowed to project owners(ACCESS may also
+    // be allowed for other users). Allowing project owners to access here will help them to view
+    // and update the config of hidden projects easily.
+    return perm.project(state.getNameKey())
+        .testOrFalse(
+            state.statePermitsRead() ? ProjectPermission.ACCESS : ProjectPermission.READ_CONFIG);
+  }
+
+  private boolean isParentAccessible(
+      Map<Project.NameKey, Boolean> checked, PermissionBackend.WithUser perm, ProjectState state)
+      throws PermissionBackendException {
+    Project.NameKey name = state.getNameKey();
+    Boolean b = checked.get(name);
+    if (b == null) {
+      try {
+        // Hidden projects(permitsRead = false) should only be accessible by the project owners.
+        // READ_CONFIG is checked here because it's only allowed to project owners(ACCESS may also
+        // be allowed for other users). Allowing project owners to access here will help them to
+        // view
+        // and update the config of hidden projects easily.
+        ProjectPermission permissionToCheck =
+            state.statePermitsRead() ? ProjectPermission.ACCESS : ProjectPermission.READ_CONFIG;
+        perm.project(name).check(permissionToCheck);
+        b = true;
+      } catch (AuthException denied) {
+        b = false;
+      }
+      checked.put(name, b);
+    }
+    return b;
+  }
+
+  private Stream<Project.NameKey> scan() throws BadRequestException {
+    if (matchPrefix != null) {
+      checkMatchOptions(matchSubstring == null && matchRegex == null);
+      return projectCache.byName(matchPrefix).stream();
+    } else if (matchSubstring != null) {
+      checkMatchOptions(matchPrefix == null && matchRegex == null);
+      return projectCache.all().stream()
+          .filter(
+              p -> p.get().toLowerCase(Locale.US).contains(matchSubstring.toLowerCase(Locale.US)));
+    } else if (matchRegex != null) {
+      checkMatchOptions(matchPrefix == null && matchSubstring == null);
+      RegexListSearcher<Project.NameKey> searcher;
+      try {
+        searcher = new RegexListSearcher<>(matchRegex, Project.NameKey::get);
+      } catch (IllegalArgumentException e) {
+        throw new BadRequestException(e.getMessage());
+      }
+      return searcher.search(projectCache.all().asList());
+    } else {
+      return projectCache.all().stream();
+    }
+  }
+
+  private static void checkMatchOptions(boolean cond) throws BadRequestException {
+    if (!cond) {
+      throw new BadRequestException("specify exactly one of p/m/r");
+    }
+  }
+
+  private void printProjectTree(
+      final PrintWriter stdout, TreeMap<Project.NameKey, ProjectNode> treeMap) {
+    final NavigableSet<ProjectNode> sortedNodes = new TreeSet<>();
+
+    // Builds the inheritance tree using a list.
+    //
+    for (ProjectNode key : treeMap.values()) {
+      if (key.isAllProjects()) {
+        sortedNodes.add(key);
+        continue;
+      }
+
+      ProjectNode node = treeMap.get(key.getParentName());
+      if (node != null) {
+        node.addChild(key);
+      } else {
+        sortedNodes.add(key);
+      }
+    }
+
+    final TreeFormatter treeFormatter = new TreeFormatter(stdout);
+    treeFormatter.printTree(sortedNodes);
+    stdout.flush();
+  }
+
+  private List<Ref> getBranchRefs(Project.NameKey projectName, Repository git) {
+    Ref[] result = new Ref[showBranch.size()];
+    try {
+      PermissionBackend.ForProject perm = permissionBackend.user(currentUser).project(projectName);
+      for (int i = 0; i < showBranch.size(); i++) {
+        Ref ref = git.findRef(showBranch.get(i));
+        if (ref != null && ref.getObjectId() != null) {
+          try {
+            perm.ref(ref.getLeaf().getName()).check(RefPermission.READ);
+            result[i] = ref;
+          } catch (AuthException e) {
+            continue;
+          }
+        }
+      }
+    } catch (IOException | PermissionBackendException e) {
+      // Fall through and return what is available.
+    }
+    return Arrays.asList(result);
+  }
+
+  private static boolean hasValidRef(List<Ref> refs) {
+    for (Ref ref : refs) {
+      if (ref != null) {
+        return true;
+      }
+    }
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ListTags.java b/java/com/google/gerrit/server/restapi/project/ListTags.java
index ac0dff9..83d29de 100644
--- a/java/com/google/gerrit/server/restapi/project/ListTags.java
+++ b/java/com/google/gerrit/server/restapi/project/ListTags.java
@@ -139,7 +139,7 @@
     tags.sort(comparing(t -> t.ref));
 
     return Response.ok(
-        new RefFilter<TagInfo>(Constants.R_TAGS)
+        new RefFilter<>(Constants.R_TAGS, (TagInfo tag) -> tag.ref)
             .start(start)
             .limit(limit)
             .subString(matchSubstring)
@@ -181,7 +181,9 @@
     if (!isConfigRef(ref.getName())) {
       // Never allow to delete the meta config branch.
       canDelete =
-          perm.testOrFalse(RefPermission.DELETE) && projectState.statePermitsWrite() ? true : null;
+          (perm.testOrFalse(RefPermission.DELETE) && projectState.statePermitsWrite())
+              ? true
+              : null;
     }
 
     ImmutableList<WebLinkInfo> webLinks = links.getTagLinks(projectState.getName(), ref.getName());
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectNode.java b/java/com/google/gerrit/server/restapi/project/ProjectNode.java
index 816c69d..ff3f588 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectNode.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectNode.java
@@ -22,7 +22,7 @@
 import java.util.NavigableSet;
 import java.util.TreeSet;
 
-/** Node of a Project in a tree formatted by {@link ListProjects}. */
+/** Node of a Project in a tree formatted by {@link ListProjectsImpl}. */
 public class ProjectNode implements TreeNode, Comparable<ProjectNode> {
   public interface Factory {
     ProjectNode create(Project project, boolean isVisible);
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
index d188bc8..a7e7894 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
@@ -25,60 +25,90 @@
 import static com.google.gerrit.server.project.TagResource.TAG_KIND;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.RestApiModule;
-import com.google.gerrit.server.config.GerritConfigListener;
-import com.google.gerrit.server.project.RefValidationHelper;
 import com.google.gerrit.server.restapi.change.CherryPickCommit;
-import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 
 public class ProjectRestApiModule extends RestApiModule {
 
   @Override
   protected void configure() {
     bind(ProjectsCollection.class);
+    bind(ListProjects.class).to(ListProjectsImpl.class);
     bind(DashboardsCollection.class);
 
-    DynamicMap.mapOf(binder(), PROJECT_KIND);
-    DynamicMap.mapOf(binder(), CHILD_PROJECT_KIND);
     DynamicMap.mapOf(binder(), BRANCH_KIND);
+    DynamicMap.mapOf(binder(), CHILD_PROJECT_KIND);
+    DynamicMap.mapOf(binder(), COMMIT_KIND);
     DynamicMap.mapOf(binder(), DASHBOARD_KIND);
     DynamicMap.mapOf(binder(), FILE_KIND);
-    DynamicMap.mapOf(binder(), COMMIT_KIND);
-    DynamicMap.mapOf(binder(), TAG_KIND);
     DynamicMap.mapOf(binder(), LABEL_KIND);
+    DynamicMap.mapOf(binder(), PROJECT_KIND);
     DynamicMap.mapOf(binder(), SUBMIT_REQUIREMENT_KIND);
-
-    DynamicSet.bind(binder(), GerritConfigListener.class).to(SetParent.class);
-    DynamicSet.bind(binder(), ProjectCreationValidationListener.class)
-        .to(CreateProject.ValidBranchListener.class);
+    DynamicMap.mapOf(binder(), TAG_KIND);
 
     create(PROJECT_KIND).to(CreateProject.class);
-    put(PROJECT_KIND).to(PutProject.class);
     get(PROJECT_KIND).to(GetProject.class);
-    get(PROJECT_KIND, "description").to(GetDescription.class);
-    put(PROJECT_KIND, "description").to(PutDescription.class);
-    delete(PROJECT_KIND, "description").to(PutDescription.class);
-
+    put(PROJECT_KIND).to(PutProject.class);
     get(PROJECT_KIND, "access").to(GetAccess.class);
     post(PROJECT_KIND, "access").to(SetAccess.class);
     put(PROJECT_KIND, "access:review").to(CreateAccessChange.class);
-    get(PROJECT_KIND, "check.access").to(CheckAccess.class);
+    put(PROJECT_KIND, "ban").to(BanCommit.class);
 
+    child(PROJECT_KIND, "branches").to(BranchesCollection.class);
+    create(BRANCH_KIND).to(CreateBranch.class);
+    put(BRANCH_KIND).to(PutBranch.class);
+    get(BRANCH_KIND).to(GetBranch.class);
+    delete(BRANCH_KIND).to(DeleteBranch.class);
+
+    child(BRANCH_KIND, "files").to(FilesCollection.class);
+    get(FILE_KIND, "content").to(GetContent.class);
+
+    get(BRANCH_KIND, "mergeable").to(CheckMergeability.class);
+    get(BRANCH_KIND, "reflog").to(GetReflog.class);
+    get(BRANCH_KIND, "suggest_reviewers").to(SuggestBranchReviewers.class);
+
+    post(PROJECT_KIND, "branches:delete").to(DeleteBranches.class);
     post(PROJECT_KIND, "check").to(Check.class);
-
-    get(PROJECT_KIND, "parent").to(GetParent.class);
-    put(PROJECT_KIND, "parent").to(SetParent.class);
+    get(PROJECT_KIND, "check.access").to(CheckAccess.class);
 
     child(PROJECT_KIND, "children").to(ChildProjectsCollection.class);
     get(CHILD_PROJECT_KIND).to(GetChildProject.class);
 
+    child(PROJECT_KIND, "commits").to(CommitsCollection.class);
+    get(COMMIT_KIND).to(GetCommit.class);
+    post(COMMIT_KIND, "cherrypick").to(CherryPickCommit.class);
+    child(COMMIT_KIND, "files").to(FilesInCommitCollection.class);
+    get(COMMIT_KIND, "in").to(CommitIncludedIn.class);
+
+    get(PROJECT_KIND, "commits:in").to(CommitsIncludedInRefs.class);
+
+    get(PROJECT_KIND, "config").to(GetConfig.class);
+    put(PROJECT_KIND, "config").to(PutConfig.class);
+
+    post(PROJECT_KIND, "create.change").to(CreateChange.class);
+
+    child(PROJECT_KIND, "dashboards").to(DashboardsCollection.class);
+    create(DASHBOARD_KIND).to(CreateDashboard.class);
+    delete(DASHBOARD_KIND).to(DeleteDashboard.class);
+    get(DASHBOARD_KIND).to(GetDashboard.class);
+    put(DASHBOARD_KIND).to(SetDashboard.class);
+
+    get(PROJECT_KIND, "description").to(GetDescription.class);
+    put(PROJECT_KIND, "description").to(PutDescription.class);
+    delete(PROJECT_KIND, "description").to(PutDescription.class);
+    get(PROJECT_KIND, "HEAD").to(GetHead.class);
+    put(PROJECT_KIND, "HEAD").to(SetHead.class);
+    post(PROJECT_KIND, "index").to(Index.class);
+
     child(PROJECT_KIND, "labels").to(LabelsCollection.class);
     create(LABEL_KIND).to(CreateLabel.class);
+    postOnCollection(LABEL_KIND).to(PostLabels.class);
     get(LABEL_KIND).to(GetLabel.class);
     put(LABEL_KIND).to(SetLabel.class);
     delete(LABEL_KIND).to(DeleteLabel.class);
-    postOnCollection(LABEL_KIND).to(PostLabels.class);
+
+    get(PROJECT_KIND, "parent").to(GetParent.class);
+    put(PROJECT_KIND, "parent").to(SetParent.class);
 
     child(PROJECT_KIND, "submit_requirements").to(SubmitRequirementsCollection.class);
     create(SUBMIT_REQUIREMENT_KIND).to(CreateSubmitRequirement.class);
@@ -86,59 +116,22 @@
     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);
-
-    put(PROJECT_KIND, "ban").to(BanCommit.class);
-
-    post(PROJECT_KIND, "index").to(Index.class);
-
-    child(PROJECT_KIND, "branches").to(BranchesCollection.class);
-    create(BRANCH_KIND).to(CreateBranch.class);
-    post(PROJECT_KIND, "create.change").to(CreateChange.class);
-    put(BRANCH_KIND).to(PutBranch.class);
-    get(BRANCH_KIND).to(GetBranch.class);
-    delete(BRANCH_KIND).to(DeleteBranch.class);
-    post(PROJECT_KIND, "branches:delete").to(DeleteBranches.class);
-    get(BRANCH_KIND, "mergeable").to(CheckMergeability.class);
-    factory(RefValidationHelper.Factory.class);
-    get(BRANCH_KIND, "reflog").to(GetReflog.class);
-    child(BRANCH_KIND, "files").to(FilesCollection.class);
-    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);
-
     child(PROJECT_KIND, "tags").to(TagsCollection.class);
     create(TAG_KIND).to(CreateTag.class);
     get(TAG_KIND).to(GetTag.class);
     put(TAG_KIND).to(PutTag.class);
     delete(TAG_KIND).to(DeleteTag.class);
+
     post(PROJECT_KIND, "tags:delete").to(DeleteTags.class);
-
-    child(PROJECT_KIND, "dashboards").to(DashboardsCollection.class);
-    create(DASHBOARD_KIND).to(CreateDashboard.class);
-    get(DASHBOARD_KIND).to(GetDashboard.class);
-    put(DASHBOARD_KIND).to(SetDashboard.class);
-    delete(DASHBOARD_KIND).to(DeleteDashboard.class);
-
-    get(PROJECT_KIND, "config").to(GetConfig.class);
-    put(PROJECT_KIND, "config").to(PutConfig.class);
-    post(COMMIT_KIND, "cherrypick").to(CherryPickCommit.class);
-
-    factory(ProjectNode.Factory.class);
   }
 
   /** Separately bind batch functionality. */
   public static class BatchModule extends RestApiModule {
     @Override
     protected void configure() {
-      get(PROJECT_KIND, "statistics.git").to(GetStatistics.class);
       post(PROJECT_KIND, "gc").to(GarbageCollect.class);
       post(PROJECT_KIND, "index.changes").to(IndexChanges.class);
+      get(PROJECT_KIND, "statistics.git").to(GetStatistics.class);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
index f9602bc..8917719 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
@@ -84,7 +84,10 @@
     if (hasQuery) {
       return queryProjects.get();
     }
-    return list.get().setFormat(OutputFormat.JSON);
+
+    ListProjects listProjects = list.get();
+    listProjects.setFormat(OutputFormat.JSON);
+    return listProjects;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/project/QueryProjects.java b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
index b219085..dc7499d 100644
--- a/java/com/google/gerrit/server/restapi/project/QueryProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/QueryProjects.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -25,6 +26,7 @@
 import com.google.gerrit.index.project.ProjectData;
 import com.google.gerrit.index.project.ProjectIndex;
 import com.google.gerrit.index.project.ProjectIndexCollection;
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.server.project.ProjectJson;
@@ -47,6 +49,7 @@
   private int limit;
   private int start;
 
+  @CanIgnoreReturnValue
   @Option(
       name = "--query",
       aliases = {"-q"},
@@ -56,6 +59,7 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   @Option(
       name = "--limit",
       aliases = {"-n"},
@@ -66,6 +70,7 @@
     return this;
   }
 
+  @CanIgnoreReturnValue
   @Option(
       name = "--start",
       aliases = {"-S"},
@@ -95,10 +100,6 @@
   }
 
   public List<ProjectInfo> apply() throws BadRequestException, MethodNotAllowedException {
-    if (Strings.isNullOrEmpty(query)) {
-      throw new BadRequestException("missing query field");
-    }
-
     ProjectIndex searchIndex = indexes.getSearchIndex();
     if (searchIndex == null) {
       throw new MethodNotAllowedException("no project index");
@@ -119,16 +120,21 @@
     }
 
     try {
-      QueryResult<ProjectData> result = queryProcessor.query(queryBuilder.parse(query));
+      QueryResult<ProjectData> result =
+          queryProcessor.query(
+              !Strings.isNullOrEmpty(query) ? queryBuilder.parse(query) : Predicate.any());
       List<ProjectData> pds = result.entities();
 
       ArrayList<ProjectInfo> projectInfos = Lists.newArrayListWithCapacity(pds.size());
       for (ProjectData pd : pds) {
         projectInfos.add(json.format(pd.getProject()));
       }
+      if (!projectInfos.isEmpty() && result.more()) {
+        projectInfos.get(projectInfos.size() - 1)._moreProjects = true;
+      }
       return projectInfos;
     } catch (QueryParseException e) {
-      throw new BadRequestException(e.getMessage());
+      throw new BadRequestException(e.getMessage(), e);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/SuggestBranchReviewers.java b/java/com/google/gerrit/server/restapi/project/SuggestBranchReviewers.java
new file mode 100644
index 0000000..239c8d9
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/SuggestBranchReviewers.java
@@ -0,0 +1,123 @@
+// 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.restapi.project;
+
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountVisibility;
+import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
+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.RestReadView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.config.GerritServerConfig;
+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.project.BranchResource;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.restapi.change.ReviewersUtil;
+import com.google.gerrit.server.restapi.change.ReviewersUtil.VisibilityControl;
+import com.google.gerrit.server.restapi.change.SuggestReviewers;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.kohsuke.args4j.Option;
+
+public class SuggestBranchReviewers extends SuggestReviewers
+    implements RestReadView<BranchResource> {
+
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> self;
+  private final ProjectCache projectCache;
+
+  private boolean excludeGroups;
+  private ReviewerState reviewerState = ReviewerState.REVIEWER;
+
+  @Option(
+      name = "--exclude-groups",
+      aliases = {"-e"},
+      usage = "exclude groups from query")
+  @CanIgnoreReturnValue
+  public SuggestBranchReviewers setExcludeGroups(boolean excludeGroups) {
+    this.excludeGroups = excludeGroups;
+    return this;
+  }
+
+  @Option(
+      name = "--reviewer-state",
+      usage =
+          "The type of reviewers that should be suggested"
+              + " (can be 'REVIEWER' or 'CC', default is 'REVIEWER')")
+  @CanIgnoreReturnValue
+  public SuggestBranchReviewers setReviewerState(ReviewerState reviewerState) {
+    this.reviewerState = reviewerState;
+    return this;
+  }
+
+  @Inject
+  SuggestBranchReviewers(
+      AccountVisibility av,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> self,
+      @GerritServerConfig Config cfg,
+      ReviewersUtil reviewersUtil,
+      ProjectCache projectCache) {
+    super(av, cfg, reviewersUtil);
+    this.permissionBackend = permissionBackend;
+    this.self = self;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public Response<List<SuggestedReviewerInfo>> apply(BranchResource rsrc)
+      throws AuthException,
+          BadRequestException,
+          IOException,
+          ConfigInvalidException,
+          PermissionBackendException {
+    if (!self.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+    if (reviewerState.equals(ReviewerState.REMOVED)) {
+      throw new BadRequestException(
+          String.format("Unsupported reviewer state: %s", ReviewerState.REMOVED));
+    }
+
+    return Response.ok(
+        reviewersUtil.suggestReviewers(
+            reviewerState,
+            null,
+            this,
+            projectCache
+                .get(rsrc.getProjectState().getNameKey())
+                .orElseThrow(illegalState(rsrc.getProjectState().getNameKey())),
+            getVisibility(rsrc.getBranchKey()),
+            excludeGroups));
+  }
+
+  private VisibilityControl getVisibility(BranchNameKey branch) {
+    return account -> {
+      return permissionBackend.absentUser(account).ref(branch).testOrFalse(RefPermission.READ);
+    };
+  }
+}
diff --git a/java/com/google/gerrit/server/rules/PrologSubmitRuleUtil.java b/java/com/google/gerrit/server/rules/PrologSubmitRuleUtil.java
new file mode 100644
index 0000000..a94fb6e
--- /dev/null
+++ b/java/com/google/gerrit/server/rules/PrologSubmitRuleUtil.java
@@ -0,0 +1,41 @@
+// 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.rules;
+
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitTypeRecord;
+import com.google.gerrit.server.query.change.ChangeData;
+
+/** Provides prolog-related operations to different callers. */
+public interface PrologSubmitRuleUtil {
+
+  /**
+   * Returns the submit-type of a change depending on the change data and the definition of the
+   * prolog rules file.
+   */
+  SubmitTypeRecord getSubmitType(ChangeData cd);
+
+  /**
+   * Returns the submit-type of a change depending on the change data and the definition of the
+   * prolog rules file.
+   */
+  SubmitTypeRecord getSubmitType(ChangeData cd, String ruleToTest, boolean skipFilters);
+
+  /** Evaluates a submit rule. */
+  SubmitRecord evaluate(ChangeData cd, String ruleToTest, boolean skipFilters);
+
+  /** Returns true if prolog rules are enabled for the project. */
+  boolean isProjectRulesEnabled();
+}
diff --git a/java/com/google/gerrit/server/rules/prolog/BUILD b/java/com/google/gerrit/server/rules/prolog/BUILD
new file mode 100644
index 0000000..5e38d06
--- /dev/null
+++ b/java/com/google/gerrit/server/rules/prolog/BUILD
@@ -0,0 +1,23 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "prolog",
+    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/server",
+        "//lib:guava",
+        "//lib:jgit",
+        "//lib/auto:auto-value-annotations",
+        "//lib/flogger:api",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/prolog:runtime",
+    ],
+)
diff --git a/java/com/google/gerrit/server/rules/PredicateClassLoader.java b/java/com/google/gerrit/server/rules/prolog/PredicateClassLoader.java
similarity index 89%
rename from java/com/google/gerrit/server/rules/PredicateClassLoader.java
rename to java/com/google/gerrit/server/rules/prolog/PredicateClassLoader.java
index 0a7a47f..67b8a60 100644
--- a/java/com/google/gerrit/server/rules/PredicateClassLoader.java
+++ b/java/com/google/gerrit/server/rules/prolog/PredicateClassLoader.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import com.google.common.collect.LinkedHashMultimap;
 import com.google.common.collect.SetMultimap;
@@ -20,13 +20,12 @@
 import java.util.Collection;
 
 /** Loads the classes for Prolog predicates. */
-public class PredicateClassLoader extends ClassLoader {
+class PredicateClassLoader extends ClassLoader {
 
   private final SetMultimap<String, ClassLoader> packageClassLoaderMap =
       LinkedHashMultimap.create();
 
-  public PredicateClassLoader(
-      PluginSetContext<PredicateProvider> predicateProviders, ClassLoader parent) {
+  PredicateClassLoader(PluginSetContext<PredicateProvider> predicateProviders, ClassLoader parent) {
     super(parent);
 
     predicateProviders.runEach(
diff --git a/java/com/google/gerrit/server/rules/PredicateProvider.java b/java/com/google/gerrit/server/rules/prolog/PredicateProvider.java
similarity index 96%
rename from java/com/google/gerrit/server/rules/PredicateProvider.java
rename to java/com/google/gerrit/server/rules/prolog/PredicateProvider.java
index 57ca7cd..6f38625 100644
--- a/java/com/google/gerrit/server/rules/PredicateProvider.java
+++ b/java/com/google/gerrit/server/rules/prolog/PredicateProvider.java
@@ -11,7 +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.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
diff --git a/java/com/google/gerrit/server/rules/PrologEnvironment.java b/java/com/google/gerrit/server/rules/prolog/PrologEnvironment.java
similarity index 99%
rename from java/com/google/gerrit/server/rules/PrologEnvironment.java
rename to java/com/google/gerrit/server/rules/prolog/PrologEnvironment.java
index 2bf4175..3610c93 100644
--- a/java/com/google/gerrit/server/rules/PrologEnvironment.java
+++ b/java/com/google/gerrit/server/rules/prolog/PrologEnvironment.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.server.AnonymousUser;
diff --git a/java/com/google/gerrit/server/rules/PrologModule.java b/java/com/google/gerrit/server/rules/prolog/PrologModule.java
similarity index 81%
rename from java/com/google/gerrit/server/rules/PrologModule.java
rename to java/com/google/gerrit/server/rules/prolog/PrologModule.java
index ebb5ec0..4b9fad1 100644
--- a/java/com/google/gerrit/server/rules/PrologModule.java
+++ b/java/com/google/gerrit/server/rules/prolog/PrologModule.java
@@ -12,12 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.server.rules.RulesCache.RulesCacheModule;
+import com.google.gerrit.server.rules.PrologSubmitRuleUtil;
+import com.google.gerrit.server.rules.SubmitRule;
+import com.google.gerrit.server.rules.prolog.RulesCache.RulesCacheModule;
 import org.eclipse.jgit.lib.Config;
 
 public class PrologModule extends FactoryModule {
@@ -31,9 +33,11 @@
   protected void configure() {
     install(new EnvironmentModule());
     install(new RulesCacheModule(config));
+    bind(RulesCache.class);
     bind(PrologEnvironment.Args.class);
     factory(PrologRuleEvaluator.Factory.class);
 
+    bind(PrologSubmitRuleUtil.class).to(PrologSubmitRuleUtilImpl.class);
     bind(SubmitRule.class).annotatedWith(Exports.named("PrologRule")).to(PrologRule.class);
   }
 
diff --git a/java/com/google/gerrit/server/rules/PrologOptions.java b/java/com/google/gerrit/server/rules/prolog/PrologOptions.java
similarity index 97%
rename from java/com/google/gerrit/server/rules/PrologOptions.java
rename to java/com/google/gerrit/server/rules/prolog/PrologOptions.java
index a176f04..124f527 100644
--- a/java/com/google/gerrit/server/rules/PrologOptions.java
+++ b/java/com/google/gerrit/server/rules/prolog/PrologOptions.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.flogger.FluentLogger;
diff --git a/java/com/google/gerrit/server/rules/PrologRule.java b/java/com/google/gerrit/server/rules/prolog/PrologRule.java
similarity index 93%
rename from java/com/google/gerrit/server/rules/PrologRule.java
rename to java/com/google/gerrit/server/rules/prolog/PrologRule.java
index 8f17fa1..13814bb 100644
--- a/java/com/google/gerrit/server/rules/PrologRule.java
+++ b/java/com/google/gerrit/server/rules/prolog/PrologRule.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
@@ -21,12 +21,13 @@
 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.SubmitRule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Optional;
 
 @Singleton
-public class PrologRule implements SubmitRule {
+class PrologRule implements SubmitRule {
   private final PrologRuleEvaluator.Factory factory;
   private final ProjectCache projectCache;
 
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/prolog/PrologRuleEvaluator.java
similarity index 99%
rename from java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
rename to java/com/google/gerrit/server/rules/prolog/PrologRuleEvaluator.java
index bfcbffc..3033dd7 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/prolog/PrologRuleEvaluator.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
diff --git a/java/com/google/gerrit/server/rules/prolog/PrologSubmitRuleUtilImpl.java b/java/com/google/gerrit/server/rules/prolog/PrologSubmitRuleUtilImpl.java
new file mode 100644
index 0000000..3d017e2
--- /dev/null
+++ b/java/com/google/gerrit/server/rules/prolog/PrologSubmitRuleUtilImpl.java
@@ -0,0 +1,56 @@
+// 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.rules.prolog;
+
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitTypeRecord;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.PrologSubmitRuleUtil;
+import com.google.inject.Singleton;
+import javax.inject.Inject;
+
+/** Implementation of {@link PrologSubmitRuleUtil}. */
+@Singleton
+public class PrologSubmitRuleUtilImpl implements PrologSubmitRuleUtil {
+  private final PrologRule prologRule;
+
+  private final RulesCache rulesCache;
+
+  @Inject
+  public PrologSubmitRuleUtilImpl(PrologRule prologRule, RulesCache rulesCache) {
+    this.prologRule = prologRule;
+    this.rulesCache = rulesCache;
+  }
+
+  @Override
+  public SubmitTypeRecord getSubmitType(ChangeData cd) {
+    return prologRule.getSubmitType(cd);
+  }
+
+  @Override
+  public SubmitTypeRecord getSubmitType(ChangeData cd, String ruleToTest, boolean skipFilters) {
+    return prologRule.getSubmitType(cd, PrologOptions.dryRunOptions(ruleToTest, skipFilters));
+  }
+
+  @Override
+  public SubmitRecord evaluate(ChangeData cd, String ruleToTest, boolean skipFilters) {
+    return prologRule.evaluate(cd, PrologOptions.dryRunOptions(ruleToTest, skipFilters));
+  }
+
+  @Override
+  public boolean isProjectRulesEnabled() {
+    return rulesCache.isProjectRulesEnabled();
+  }
+}
diff --git a/java/com/google/gerrit/server/rules/RuleUtil.java b/java/com/google/gerrit/server/rules/prolog/RuleUtil.java
similarity index 89%
rename from java/com/google/gerrit/server/rules/RuleUtil.java
rename to java/com/google/gerrit/server/rules/prolog/RuleUtil.java
index f4e7eff..16fd9af 100644
--- a/java/com/google/gerrit/server/rules/RuleUtil.java
+++ b/java/com/google/gerrit/server/rules/prolog/RuleUtil.java
@@ -12,18 +12,18 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import org.eclipse.jgit.lib.Config;
 
 /** Provides utility methods for configuring and running Prolog rules inside Gerrit. */
-public class RuleUtil {
+class RuleUtil {
 
   /**
    * Returns the reduction limit to be applied to the Prolog machine to prevent infinite loops and
    * other forms of computational overflow.
    */
-  public static int reductionLimit(Config gerritConfig) {
+  static int reductionLimit(Config gerritConfig) {
     int limit = gerritConfig.getInt("rules", null, "reductionLimit", 100000);
     return limit <= 0 ? Integer.MAX_VALUE : limit;
   }
@@ -33,7 +33,7 @@
    * loops and other forms of computational overflow. The compiled reduction limit should be used
    * when user-provided Prolog code is compiled by the interpreter before the limit gets applied.
    */
-  public static int compileReductionLimit(Config gerritConfig) {
+  static int compileReductionLimit(Config gerritConfig) {
     int limit =
         gerritConfig.getInt(
             "rules",
diff --git a/java/com/google/gerrit/server/rules/RulesCache.java b/java/com/google/gerrit/server/rules/prolog/RulesCache.java
similarity index 99%
rename from java/com/google/gerrit/server/rules/RulesCache.java
rename to java/com/google/gerrit/server/rules/prolog/RulesCache.java
index 167b84e..3d36b4f 100644
--- a/java/com/google/gerrit/server/rules/RulesCache.java
+++ b/java/com/google/gerrit/server/rules/prolog/RulesCache.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import static com.google.gerrit.server.project.ProjectConfig.RULES_PL_FILE;
 import static com.googlecode.prolog_cafe.lang.PrologMachineCopy.save;
diff --git a/java/com/google/gerrit/server/rules/StoredValue.java b/java/com/google/gerrit/server/rules/prolog/StoredValue.java
similarity index 98%
rename from java/com/google/gerrit/server/rules/StoredValue.java
rename to java/com/google/gerrit/server/rules/prolog/StoredValue.java
index d371bf6..d70885c 100644
--- a/java/com/google/gerrit/server/rules/StoredValue.java
+++ b/java/com/google/gerrit/server/rules/prolog/StoredValue.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import com.googlecode.prolog_cafe.exceptions.SystemException;
 import com.googlecode.prolog_cafe.lang.Prolog;
diff --git a/java/com/google/gerrit/server/rules/StoredValues.java b/java/com/google/gerrit/server/rules/prolog/StoredValues.java
similarity index 99%
rename from java/com/google/gerrit/server/rules/StoredValues.java
rename to java/com/google/gerrit/server/rules/prolog/StoredValues.java
index dbaefb9..774da38 100644
--- a/java/com/google/gerrit/server/rules/StoredValues.java
+++ b/java/com/google/gerrit/server/rules/prolog/StoredValues.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index dc83d4a..123a873 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -22,6 +22,8 @@
 import static com.google.gerrit.server.schema.AclUtil.grant;
 import static com.google.gerrit.server.schema.AclUtil.rule;
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.INIT_REPO;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.gerrit.common.Version;
 import com.google.gerrit.common.data.GlobalCapability;
@@ -32,13 +34,12 @@
 import com.google.gerrit.entities.PermissionRule.Action;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.Sequence;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.notedb.RepoSequence;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
@@ -48,6 +49,7 @@
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
@@ -242,16 +244,22 @@
 
   private void initSequences(Repository git, BatchRefUpdate bru, int firstChangeId)
       throws IOException {
-    if (git.exactRef(REFS_SEQUENCES + Sequences.NAME_CHANGES) == null) {
+    if (git.exactRef(REFS_SEQUENCES + Sequence.NAME_CHANGES) == null) {
       // Can't easily reuse the inserter from MetaDataUpdate, but this shouldn't slow down site
       // initialization unduly.
       try (ObjectInserter ins = git.newObjectInserter()) {
-        bru.addCommand(RepoSequence.storeNew(ins, Sequences.NAME_CHANGES, firstChangeId));
+        bru.addCommand(createNewChangeSequence(ins, firstChangeId));
         ins.flush();
       }
     }
   }
 
+  private ReceiveCommand createNewChangeSequence(ObjectInserter ins, int val) throws IOException {
+    ObjectId newId = ins.insert(OBJ_BLOB, Integer.toString(val).getBytes(UTF_8));
+    return new ReceiveCommand(
+        ObjectId.zeroId(), newId, RefNames.REFS_SEQUENCES + Sequence.NAME_CHANGES);
+  }
+
   private void execute(Repository git, BatchRefUpdate bru) throws IOException {
     try (RevWalk rw = new RevWalk(git)) {
       bru.execute(rw, NullProgressMonitor.INSTANCE);
diff --git a/java/com/google/gerrit/server/schema/AllProjectsInput.java b/java/com/google/gerrit/server/schema/AllProjectsInput.java
index a079050..cfb9754 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsInput.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsInput.java
@@ -24,7 +24,7 @@
 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 com.google.gerrit.server.Sequences;
 import java.util.Optional;
 
 @AutoValue
diff --git a/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
index 8cc140e..9079836 100644
--- a/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
@@ -353,6 +353,22 @@
   }
 
   @Override
+  public void clearReviewedBy(Account.Id accountId) {
+    try (TraceTimer ignored =
+            TraceContext.newTimer(
+                "Clear all reviewed flags by user",
+                Metadata.builder().accountId(accountId.get()).build());
+        Connection con = ds.getConnection();
+        PreparedStatement stmt =
+            con.prepareStatement("DELETE FROM account_patch_reviews WHERE account_id = ?")) {
+      stmt.setInt(1, accountId.get());
+      stmt.executeUpdate();
+    } catch (SQLException e) {
+      throw convertError("delete", e);
+    }
+  }
+
+  @Override
   public Optional<PatchSetWithReviewedFiles> findReviewed(PatchSet.Id psId, Account.Id accountId) {
     try (TraceTimer ignored =
             TraceContext.newTimer(
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java b/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java
index 57ec7ef..d60a10a 100644
--- a/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java
@@ -23,10 +23,10 @@
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.Sequence;
 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.notedb.Sequences;
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -151,7 +151,7 @@
     //    In this case the server literally will not start under 2.16. We assume the user will fix
     //    this and get 2.16 running rather than abandoning 2.16 and jumping to 3.0 at this point.
     try (Repository allUsers = repoManager.openRepository(allUsersName)) {
-      if (allUsers.exactRef(RefNames.REFS_SEQUENCES + Sequences.NAME_GROUPS) == null) {
+      if (allUsers.exactRef(RefNames.REFS_SEQUENCES + Sequence.NAME_GROUPS) == null) {
         throw new StorageException(
             "You appear to be upgrading to 3.x from a version prior to 2.16; you must upgrade to"
                 + " 2.16.x first");
diff --git a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
index 38e45ab..56c6fa8 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.schema;
 
+import static com.google.gerrit.server.Sequence.LightweightGroups;
+
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
@@ -21,8 +23,8 @@
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.git.RefUpdateUtil;
-import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.Sequence;
 import com.google.gerrit.server.account.GroupUuid;
 import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -37,7 +39,6 @@
 import com.google.gerrit.server.group.db.InternalGroupCreation;
 import com.google.gerrit.server.index.group.GroupIndex;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType;
 import com.google.inject.Inject;
@@ -45,7 +46,6 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 
@@ -58,12 +58,10 @@
   private final AllProjectsCreator allProjectsCreator;
   private final AllUsersCreator allUsersCreator;
   private final AllUsersName allUsersName;
+  private final Sequence groupsSequence;
   private final PersonIdent serverUser;
   private final GroupIndexCollection indexCollection;
   private final String serverId;
-
-  private final Config config;
-  private final MetricMaker metricMaker;
   private final AllProjectsName allProjectsName;
 
   @Inject
@@ -72,23 +70,21 @@
       AllProjectsCreator ap,
       AllUsersCreator auc,
       AllUsersName allUsersName,
+      @LightweightGroups Sequence groupsSequence,
       @GerritPersonIdent PersonIdent au,
       GroupIndexCollection ic,
       String serverId,
-      Config config,
-      MetricMaker metricMaker,
       AllProjectsName apName) {
     this.repoManager = repoManager;
     allProjectsCreator = ap;
     allUsersCreator = auc;
     this.allUsersName = allUsersName;
+    this.groupsSequence = groupsSequence;
     serverUser = au;
     indexCollection = ic;
     this.serverId = serverId;
 
-    this.config = config;
     this.allProjectsName = apName;
-    this.metricMaker = metricMaker;
   }
 
   @Override
@@ -106,19 +102,9 @@
       // We have to create the All-Users repository before we can use it to store the groups in it.
       allUsersCreator.setAdministrators(admins).create();
 
-      // Don't rely on injection to construct Sequences, as the default GitReferenceUpdated has a
-      // thick dependency stack which may not all be available at schema creation time.
-      Sequences seqs =
-          new Sequences(
-              config,
-              repoManager,
-              GitReferenceUpdated.DISABLED,
-              allProjectsName,
-              allUsersName,
-              metricMaker);
       try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
-        createAdminsGroup(seqs, allUsersRepo, admins);
-        createBatchUsersGroup(seqs, allUsersRepo, serviceUsers, admins.getUUID());
+        createAdminsGroup(allUsersRepo, admins);
+        createBatchUsersGroup(allUsersRepo, serviceUsers, admins.getUUID());
       }
     }
   }
@@ -132,10 +118,9 @@
     }
   }
 
-  private void createAdminsGroup(
-      Sequences seqs, Repository allUsersRepo, GroupReference groupReference)
+  private void createAdminsGroup(Repository allUsersRepo, GroupReference groupReference)
       throws IOException, ConfigInvalidException {
-    InternalGroupCreation groupCreation = getGroupCreation(seqs, groupReference);
+    InternalGroupCreation groupCreation = getGroupCreation(groupReference);
     GroupDelta groupDelta =
         GroupDelta.builder().setDescription("Gerrit Site Administrators").build();
 
@@ -143,12 +128,9 @@
   }
 
   private void createBatchUsersGroup(
-      Sequences seqs,
-      Repository allUsersRepo,
-      GroupReference groupReference,
-      AccountGroup.UUID adminsGroupUuid)
+      Repository allUsersRepo, GroupReference groupReference, AccountGroup.UUID adminsGroupUuid)
       throws IOException, ConfigInvalidException {
-    InternalGroupCreation groupCreation = getGroupCreation(seqs, groupReference);
+    InternalGroupCreation groupCreation = getGroupCreation(groupReference);
     GroupDelta groupDelta =
         GroupDelta.builder()
             .setDescription("Users who perform batch actions on Gerrit")
@@ -223,8 +205,8 @@
     return GroupReference.create(groupUuid, name);
   }
 
-  private InternalGroupCreation getGroupCreation(Sequences seqs, GroupReference groupReference) {
-    int next = seqs.nextGroupId();
+  private InternalGroupCreation getGroupCreation(GroupReference groupReference) {
+    int next = groupsSequence.next();
     return InternalGroupCreation.builder()
         .setNameKey(AccountGroup.nameKey(groupReference.getName()))
         .setId(AccountGroup.id(next))
diff --git a/java/com/google/gerrit/server/schema/SchemaModule.java b/java/com/google/gerrit/server/schema/SchemaModule.java
index 9593522..7afc1f5 100644
--- a/java/com/google/gerrit/server/schema/SchemaModule.java
+++ b/java/com/google/gerrit/server/schema/SchemaModule.java
@@ -14,12 +14,14 @@
 
 package com.google.gerrit.server.schema;
 
+import static com.google.gerrit.server.Sequence.LightweightGroups;
 import static com.google.inject.Scopes.SINGLETON;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.GerritPersonIdentProvider;
+import com.google.gerrit.server.Sequence;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.gerrit.server.config.AllUsersName;
@@ -31,6 +33,7 @@
 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.gerrit.server.notedb.RepoSequence.DisabledGitRefUpdatedRepoGroupsSequenceProvider;
 import com.google.inject.TypeLiteral;
 import org.eclipse.jgit.lib.PersonIdent;
 
@@ -64,6 +67,9 @@
     // SchemaCreatorImpl, so it's needed.
     // TODO(dborowitz): Is there any way to untangle this?
     bind(GroupIndexCollection.class);
+    bind(Sequence.class)
+        .annotatedWith(LightweightGroups.class)
+        .toProvider(DisabledGitRefUpdatedRepoGroupsSequenceProvider.class);
     bind(SchemaCreator.class).to(SchemaCreatorImpl.class);
   }
 }
diff --git a/java/com/google/gerrit/server/schema/Schema_182.java b/java/com/google/gerrit/server/schema/Schema_182.java
index a61a175..afb6aac 100644
--- a/java/com/google/gerrit/server/schema/Schema_182.java
+++ b/java/com/google/gerrit/server/schema/Schema_182.java
@@ -28,8 +28,9 @@
   public void upgrade(Arguments args, UpdateUI ui) throws Exception {
     AllUsersName allUsers = args.allUsers;
     GitRepositoryManager gitRepoManager = args.repoManager;
-    DeleteZombieCommentsRefs cleanup =
-        new DeleteZombieCommentsRefs(allUsers, gitRepoManager, 100, ui::message);
-    cleanup.execute();
+    try (DeleteZombieCommentsRefs cleanup =
+        new DeleteZombieCommentsRefs(allUsers, gitRepoManager, 100, ui::message)) {
+      cleanup.execute();
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/ssh/SshAddressesModule.java b/java/com/google/gerrit/server/ssh/SshAddressesModule.java
index 1159e06..c38df6b 100644
--- a/java/com/google/gerrit/server/ssh/SshAddressesModule.java
+++ b/java/com/google/gerrit/server/ssh/SshAddressesModule.java
@@ -28,6 +28,7 @@
 import java.util.List;
 import org.eclipse.jgit.lib.Config;
 
+@SuppressWarnings("ProvidesMethodOutsideOfModule")
 public class SshAddressesModule extends AbstractModule {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
diff --git a/java/com/google/gerrit/server/submit/CherryPick.java b/java/com/google/gerrit/server/submit/CherryPick.java
index 0471b67..7fe5e69 100644
--- a/java/com/google/gerrit/server/submit/CherryPick.java
+++ b/java/com/google/gerrit/server/submit/CherryPick.java
@@ -34,6 +34,7 @@
 import java.io.IOException;
 import java.util.Collection;
 import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -102,8 +103,10 @@
       RevCommit mergeTip = args.mergeTip.getCurrentTip();
       args.rw.parseBody(mergeTip);
       String cherryPickCmtMsg = args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
-
-      PersonIdent committer = ctx.newCommitterIdent(args.caller);
+      PersonIdent committer =
+          Optional.ofNullable(toMerge.getCommitterIdent())
+              .map(ident -> ctx.newCommitterIdent(ident.getEmailAddress(), args.caller))
+              .orElseGet(() -> ctx.newCommitterIdent(args.caller));
       try {
         newCommit =
             args.mergeUtil.createCherryPickFromCommit(
diff --git a/java/com/google/gerrit/server/submit/CommitMergeStatus.java b/java/com/google/gerrit/server/submit/CommitMergeStatus.java
index 4638bfa..f7af684 100644
--- a/java/com/google/gerrit/server/submit/CommitMergeStatus.java
+++ b/java/com/google/gerrit/server/submit/CommitMergeStatus.java
@@ -52,7 +52,7 @@
       "Marking change merged without cherry-picking to branch, as the resulting commit would be"
           + " empty."),
 
-  MISSING_DEPENDENCY("Depends on change that was not submitted."),
+  MISSING_DEPENDENCY("Depends on commit that cannot be merged."),
 
   MANUAL_RECURSIVE_MERGE(
       "The change requires a local merge to resolve.\n"
diff --git a/java/com/google/gerrit/server/submit/EmailMerge.java b/java/com/google/gerrit/server/submit/EmailMerge.java
index 7aa3716..47fef1a 100644
--- a/java/com/google/gerrit/server/submit/EmailMerge.java
+++ b/java/com/google/gerrit/server/submit/EmailMerge.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.submit;
 
+import static com.google.gerrit.server.mail.EmailFactories.CHANGE_MERGED;
+
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
@@ -23,8 +25,10 @@
 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.MergedSender;
+import com.google.gerrit.server.mail.EmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -49,7 +53,7 @@
   }
 
   private final ExecutorService sendEmailsExecutor;
-  private final MergedSender.Factory mergedSenderFactory;
+  private final EmailFactories emailFactories;
   private final ThreadLocalRequestContext requestContext;
   private final MessageIdGenerator messageIdGenerator;
 
@@ -63,7 +67,7 @@
   @Inject
   EmailMerge(
       @SendEmailExecutor ExecutorService executor,
-      MergedSender.Factory mergedSenderFactory,
+      EmailFactories emailFactories,
       ThreadLocalRequestContext requestContext,
       MessageIdGenerator messageIdGenerator,
       @Assisted Project.NameKey project,
@@ -73,7 +77,7 @@
       @Assisted RepoView repoView,
       @Assisted String stickyApprovalDiff) {
     this.sendEmailsExecutor = executor;
-    this.mergedSenderFactory = mergedSenderFactory;
+    this.emailFactories = emailFactories;
     this.requestContext = requestContext;
     this.messageIdGenerator = messageIdGenerator;
     this.project = project;
@@ -93,18 +97,20 @@
   public void run() {
     RequestContext old = requestContext.setContext(this);
     try {
-      MergedSender emailSender =
-          mergedSenderFactory.create(
+      ChangeEmail changeEmail =
+          emailFactories.createChangeEmail(
               project,
               change.getId(),
-              Optional.ofNullable(Strings.emptyToNull(stickyApprovalDiff)));
+              emailFactories.createMergedChangeEmail(
+                  Optional.ofNullable(Strings.emptyToNull(stickyApprovalDiff))));
+      OutgoingEmail outgoingEmail = emailFactories.createOutgoingEmail(CHANGE_MERGED, changeEmail);
       if (submitter != null) {
-        emailSender.setFrom(submitter.getAccountId());
+        outgoingEmail.setFrom(submitter.getAccountId());
       }
-      emailSender.setNotify(notify);
-      emailSender.setMessageId(
+      outgoingEmail.setNotify(notify);
+      outgoingEmail.setMessageId(
           messageIdGenerator.fromChangeUpdate(repoView, change.currentPatchSetId()));
-      emailSender.send();
+      outgoingEmail.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email merged notification for %s", change.getId());
     } finally {
diff --git a/java/com/google/gerrit/server/submit/MergeMetrics.java b/java/com/google/gerrit/server/submit/MergeMetrics.java
index 4c11925..b56d9ef 100644
--- a/java/com/google/gerrit/server/submit/MergeMetrics.java
+++ b/java/com/google/gerrit/server/submit/MergeMetrics.java
@@ -57,15 +57,19 @@
   public void countChangesThatWereSubmittedWithRebaserApproval(ChangeData cd) {
     if (isRebaseOnBehalfOfUploader(cd)
         && hasCodeReviewApprovalOfRealUploader(cd)
+        && !hasCodeReviewApprovalOfUserThatIsNotTheRealUploader(cd)
         && ignoresCodeReviewApprovalsOfUploader(cd)) {
       // 1. The patch set that is being submitted was created by rebasing on behalf of the uploader.
       // The uploader of the patch set is the original uploader on whom's behalf the rebase was
       // done. The real uploader is the user that did the rebase on behalf of the uploader (e.g. by
       // clicking on the rebase button).
       //
-      // 2. The change has Code-Review approvals of the real uploader (aka the rebaser).
+      // 2. The change has a Code-Review approval of the real uploader (aka the rebaser).
       //
-      // 3. Code-Review approvals of the uploader are ignored.
+      // 3. The change doesn't have a Code-Review approval of any other user (a user that is not the
+      // real uploader).
+      //
+      // 4. Code-Review approvals of the uploader are ignored.
       //
       // If instead of a rebase on behalf of the uploader a normal rebase would have been done the
       // rebaser would have been the uploader of the patch set. In this case the Code-Review
@@ -99,6 +103,16 @@
     return hasCodeReviewApprovalOfRealUploader;
   }
 
+  private boolean hasCodeReviewApprovalOfUserThatIsNotTheRealUploader(ChangeData cd) {
+    boolean hasCodeReviewApprovalOfUserThatIsNotTheRealUploader =
+        cd.currentApprovals().stream()
+            .anyMatch(psa -> !psa.accountId().equals(cd.currentPatchSet().realUploader()));
+    logger.atFine().log(
+        "hasCodeReviewApprovalOfUserThatIsNotTheRealUploader = %s",
+        hasCodeReviewApprovalOfUserThatIsNotTheRealUploader);
+    return hasCodeReviewApprovalOfUserThatIsNotTheRealUploader;
+  }
+
   private boolean ignoresCodeReviewApprovalsOfUploader(ChangeData cd) {
     for (SubmitRequirement submitRequirement : cd.submitRequirements().keySet()) {
       try {
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 8ab8f6a..10401cc 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.update.RetryableAction.ActionType.INDEX_QUERY;
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.MERGE_CHANGE;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
@@ -464,20 +465,8 @@
 
       logger.atFine().log("Beginning integration of %s", change);
       try {
-        ChangeSet indexBackedChangeSet =
-            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.
-          change = changeDataFactory.create(change).reloadChange();
-          if (!change.isNew()) {
-            throw new ResourceConflictException("change is " + ChangeUtil.status(change));
-          }
-          throw new IllegalStateException(
-              String.format("change %s missing from %s", change.getId(), indexBackedChangeSet));
-        }
+
+        ChangeSet indexBackedChangeSet = completeMergeChangeSetWithRetry(change);
 
         if (indexBackedChangeSet.furtherHiddenChanges()) {
           throw new AuthException(
@@ -565,6 +554,49 @@
     }
   }
 
+  private ChangeSet completeMergeChangeSetWithRetry(Change change)
+      throws IOException, ResourceConflictException {
+    try {
+      mergeSuperSet.setMergeOpRepoManager(orm);
+      // Index can be stale for O(seconds), so attempting to merge the change right after it is
+      // updated can result in an exception.
+      // Reattempt evaluating the change set with the standard INDEX_QUERY retry timeout
+      ChangeSet resultSet =
+          retryHelper
+              .action(
+                  INDEX_QUERY,
+                  "completeMergeChangeSet",
+                  () -> {
+                    Change reloadChange = change;
+                    ChangeSet indexBackedMergeChangeSet =
+                        mergeSuperSet.completeChangeSet(
+                            reloadChange, caller, /* includingTopicClosure= */ false);
+                    if (!indexBackedMergeChangeSet.ids().contains(reloadChange.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.
+                      reloadChange = changeDataFactory.create(reloadChange).reloadChange();
+                      if (!reloadChange.isNew()) {
+                        throw new ResourceConflictException(
+                            "change is " + ChangeUtil.status(reloadChange));
+                      }
+                      throw new IOException(
+                          String.format(
+                              "change %s missing from %s",
+                              reloadChange.getId(), indexBackedMergeChangeSet));
+                    }
+                    return indexBackedMergeChangeSet;
+                  })
+              .retryOn(IOException.class::isInstance)
+              .call();
+      return resultSet;
+    } catch (ResourceConflictException | IOException e) {
+      throw e;
+    } catch (Exception e) {
+      throw new IOException("Computing mergeSuperset has failed", e);
+    }
+  }
+
   private void openRepoManager() {
     if (orm != null) {
       orm.close();
diff --git a/java/com/google/gerrit/server/submit/MergeSuperSet.java b/java/com/google/gerrit/server/submit/MergeSuperSet.java
index 8581e20..cef2cbb 100644
--- a/java/com/google/gerrit/server/submit/MergeSuperSet.java
+++ b/java/com/google/gerrit/server/submit/MergeSuperSet.java
@@ -18,6 +18,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Strings;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -85,6 +86,7 @@
     return config.getBoolean("change", null, "submitWholeTopic", false);
   }
 
+  @CanIgnoreReturnValue
   public MergeSuperSet setMergeOpRepoManager(MergeOpRepoManager orm) {
     checkState(this.orm == null);
     this.orm = requireNonNull(orm);
diff --git a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index 020c290..d8f8032 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -41,6 +41,7 @@
 import java.io.IOException;
 import java.util.Collection;
 import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
@@ -169,7 +170,10 @@
         RevCommit mergeTip = args.mergeTip.getCurrentTip();
         args.rw.parseBody(mergeTip);
         String cherryPickCmtMsg = args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
-        PersonIdent committer = ctx.newCommitterIdent(args.caller);
+        PersonIdent committer =
+            Optional.ofNullable(toMerge.getCommitterIdent())
+                .map(ident -> ctx.newCommitterIdent(ident.getEmailAddress(), args.caller))
+                .orElseGet(() -> ctx.newCommitterIdent(args.caller));
         try {
           newCommit =
               args.mergeUtil.createCherryPickFromCommit(
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index 9250513..532b345 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -19,6 +19,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableMultiset.toImmutableMultiset;
 import static com.google.common.flogger.LazyArgs.lazy;
+import static com.google.gerrit.common.UsedAt.Project.GOOGLE;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toMap;
@@ -31,6 +32,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Multiset;
@@ -39,6 +41,7 @@
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
@@ -52,14 +55,14 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.RefLogIdentityProvider;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.NotifyResolver;
-import com.google.gerrit.server.experiments.ExperimentFeatures;
-import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.AttentionSetObserver;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -93,6 +96,7 @@
 import java.util.TreeMap;
 import java.util.function.Function;
 import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
@@ -182,7 +186,7 @@
       // Fire ref update events only after all mutations are finished, since callers may assume a
       // patch set ref being created means the change was created, or a branch advancing meaning
       // some changes were closed.
-      updates.forEach(BatchUpdate::fireRefChangeEvent);
+      updates.forEach(BatchUpdate::fireRefChangeEvents);
 
       if (!dryrun) {
         for (BatchUpdate u : updates) {
@@ -425,10 +429,10 @@
   private final Map<Change.Id, Change> newChanges = new HashMap<>();
   private final List<OpData<RepoOnlyOp>> repoOnlyOps = new ArrayList<>();
   private final Map<Change.Id, NotifyHandling> perChangeNotifyHandling = new HashMap<>();
-  private final ExperimentFeatures experimentFeatures;
+  private final Config gerritConfig;
 
   private RepoView repoView;
-  private BatchRefUpdate batchRefUpdate;
+  private ImmutableMultimap<Project.NameKey, BatchRefUpdate> batchRefUpdate;
   private ImmutableListMultimap<ProjectChangeKey, AttentionSetUpdate> attentionSetUpdates;
 
   private boolean executed;
@@ -452,10 +456,11 @@
       GitReferenceUpdated gitRefUpdated,
       RefLogIdentityProvider refLogIdentityProvider,
       AttentionSetObserver attentionSetObserver,
-      ExperimentFeatures experimentFeatures,
+      @GerritServerConfig Config gerritConfig,
       @Assisted Project.NameKey project,
       @Assisted CurrentUser user,
       @Assisted Instant when) {
+    this.gerritConfig = gerritConfig;
     this.repoManager = repoManager;
     this.accountCache = accountCache;
     this.changeDataFactory = changeDataFactory;
@@ -466,7 +471,6 @@
     this.gitRefUpdated = gitRefUpdated;
     this.refLogIdentityProvider = refLogIdentityProvider;
     this.attentionSetObserver = attentionSetObserver;
-    this.experimentFeatures = experimentFeatures;
     this.project = project;
     this.user = user;
     this.when = when;
@@ -666,15 +670,17 @@
     }
   }
 
+  // For upstream implementation, AccessPath.WEB_BROWSER is never set, so the method will always
+  // return false.
+  @UsedAt(GOOGLE)
   private boolean indexAsync() {
-    return experimentFeatures.isFeatureEnabled(
-        ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_DO_NOT_AWAIT_CHANGE_INDEXING);
+    return user.getAccessPath().equals(AccessPath.WEB_BROWSER)
+        && gerritConfig.getBoolean("index", "indexChangesAsync", false);
   }
 
-  private void fireRefChangeEvent() {
-    if (batchRefUpdate != null) {
-      gitRefUpdated.fire(project, batchRefUpdate, getAccount().orElse(null));
-    }
+  private void fireRefChangeEvents() {
+    batchRefUpdate.forEach(
+        (projectName, bru) -> gitRefUpdated.fire(projectName, bru, getAccount().orElse(null)));
   }
 
   private void fireAttentionSetUpdateEvents(Map<Change.Id, ChangeData> changeDatas) {
@@ -721,11 +727,14 @@
     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())));
+      ImmutableMultimap<Project.NameKey, BatchRefUpdate> bru = BatchUpdate.this.batchRefUpdate;
+      return !(bru.isEmpty()
+          || bru.values().stream()
+              .allMatch(
+                  batchRefUpdate ->
+                      batchRefUpdate.getCommands().isEmpty()
+                          || batchRefUpdate.getCommands().stream()
+                              .allMatch(cmd -> RefNames.isRefsDraftsComments(cmd.getRefName()))));
     }
 
     ImmutableList<ListenableFuture<ChangeData>> startIndexFutures() {
@@ -751,13 +760,19 @@
         }
       }
       if (indexAsync) {
+        logger.atFine().log(
+            "Asynchronously reindexing changes, %s in project %s", results.keySet(), project.get());
         // We want to index asynchronously. However, the callers will await all
         // index futures. This allows us to - even in synchronous case -
         // parallelize indexing changes.
         // Returning immediate futures for newly-created change data objects
         // while letting the actual futures go will make actual indexing
         // asynchronous.
-        return results.keySet().stream()
+        // Only return results for the change modifications (ChangeResult.DELETE and
+        // ChangeResult.SKIPPED are filtered out). For sync path, they are filtered out later on.
+        return results.entrySet().stream()
+            .filter(e -> e.getValue().equals(ChangeResult.UPSERTED))
+            .map(Map.Entry::getKey)
             .map(cId -> Futures.immediateFuture(changeDataFactory.create(project, cId)))
             .collect(toImmutableList());
       }
@@ -778,7 +793,7 @@
     ChangesHandle handle =
         new ChangesHandle(
             updateManagerFactory
-                .create(project)
+                .create(project, user)
                 .setBatchUpdateListeners(batchUpdateListeners)
                 .setChangeRepo(
                     repo, repoView.getRevWalk(), repoView.getInserter(), repoView.getCommands()),
diff --git a/java/com/google/gerrit/server/update/Context.java b/java/com/google/gerrit/server/update/Context.java
index aa41d90..4e5d73f 100644
--- a/java/com/google/gerrit/server/update/Context.java
+++ b/java/com/google/gerrit/server/update/Context.java
@@ -164,4 +164,16 @@
   default PersonIdent newCommitterIdent(IdentifiedUser user) {
     return user.newCommitterIdent(getWhen(), getZoneId());
   }
+
+  /**
+   * Creates a committer {@link PersonIdent} for the given user. The identity will be created with
+   * the given email if the user is allowed to use it, otherwise fallback to preferred email.
+   *
+   * @param user user for which a committer {@link PersonIdent} should be created
+   * @param email committer email of the source commit
+   * @return the created committer {@link PersonIdent}
+   */
+  default PersonIdent newCommitterIdent(String email, IdentifiedUser user) {
+    return user.newCommitterIdent(email, getWhen(), getZoneId()).orElseGet(this::newCommitterIdent);
+  }
 }
diff --git a/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java b/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
index 8a216cd..813bee9 100644
--- a/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
+++ b/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
@@ -39,6 +39,7 @@
   private ImmutableList<BatchUpdate> batchUpdates = ImmutableList.of();
   private boolean dryrun;
 
+  @SuppressWarnings("ProvidesMethodOutsideOfModule")
   public static class SuperprojectUpdateSubmissionListenerModule extends AbstractModule {
     @Provides
     @SuperprojectUpdateOnSubmission
diff --git a/java/com/google/gerrit/server/update/context/RefUpdateContext.java b/java/com/google/gerrit/server/update/context/RefUpdateContext.java
index 56d536a..a8c11df 100644
--- a/java/com/google/gerrit/server/update/context/RefUpdateContext.java
+++ b/java/com/google/gerrit/server/update/context/RefUpdateContext.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableList;
 import java.util.ArrayDeque;
 import java.util.Deque;
+import java.util.Optional;
 
 /**
  * Passes additional information about an operation to the {@code BatchRefUpdate#execute} method.
@@ -126,7 +127,15 @@
   /** Opens a context of a give type. */
   public static RefUpdateContext open(RefUpdateType updateType) {
     checkArgument(updateType != RefUpdateType.OTHER, "The OTHER type is for internal use only.");
-    return open(new RefUpdateContext(updateType));
+    checkArgument(
+        updateType != RefUpdateType.DIRECT_PUSH,
+        "openDirectPush method with justification must be used to open DIRECT_PUSH context.");
+    return open(new RefUpdateContext(updateType, Optional.empty()));
+  }
+
+  /** Opens a direct push context with an optional justification. */
+  public static RefUpdateContext openDirectPush(Optional<String> justification) {
+    return open(new RefUpdateContext(RefUpdateType.DIRECT_PUSH, justification));
   }
 
   /** Returns the list of opened contexts; the first element is the outermost context. */
@@ -140,13 +149,15 @@
   }
 
   private final RefUpdateType updateType;
+  private final Optional<String> justification;
 
-  private RefUpdateContext(RefUpdateType updateType) {
+  private RefUpdateContext(RefUpdateType updateType, Optional<String> justification) {
     this.updateType = updateType;
+    this.justification = justification;
   }
 
-  protected RefUpdateContext() {
-    this(RefUpdateType.OTHER);
+  protected RefUpdateContext(Optional<String> justification) {
+    this(RefUpdateType.OTHER, justification);
   }
 
   protected static final Deque<RefUpdateContext> getCurrent() {
@@ -161,12 +172,18 @@
   /**
    * Returns the type of {@link RefUpdateContext}.
    *
-   * <p>For descendants, always return {@link RefUpdateType#OTHER}
+   * <p>For descendants, always return {@link RefUpdateType#OTHER} (except known descendants defined
+   * as nested classes).
    */
   public final RefUpdateType getUpdateType() {
     return updateType;
   }
 
+  /** Returns the justification for the operation. */
+  public final Optional<String> getJustification() {
+    return justification;
+  }
+
   /** Closes the current context. */
   @Override
   public void close() {
diff --git a/java/com/google/gerrit/server/util/AttentionSetEmail.java b/java/com/google/gerrit/server/util/AttentionSetEmail.java
index 948b6e3..82e5bd1 100644
--- a/java/com/google/gerrit/server/util/AttentionSetEmail.java
+++ b/java/com/google/gerrit/server/util/AttentionSetEmail.java
@@ -14,17 +14,23 @@
 
 package com.google.gerrit.server.util;
 
+import static com.google.gerrit.server.mail.EmailFactories.ATTENTION_SET_ADDED;
+import static com.google.gerrit.server.mail.EmailFactories.ATTENTION_SET_REMOVED;
+
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.CurrentUser;
 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.EmailFactories;
+import com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator.AttentionSetChange;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 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.mail.send.OutgoingEmail;
 import com.google.gerrit.server.update.Context;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -42,15 +48,14 @@
     /**
      * factory for sending an email when adding users to the attention set or removing them from it.
      *
-     * @param sender sender in charge of sending the email, can be {@link AddToAttentionSetSender}
-     *     or {@link RemoveFromAttentionSetSender}.
+     * @param attentionSetChange whether the user is added or removed.
      * @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 attentionUserId the user added/removed.
      */
     AttentionSetEmail create(
-        AttentionSetSender sender,
+        AttentionSetChange attentionSetChange,
         Context ctx,
         Change change,
         String reason,
@@ -66,7 +71,8 @@
       ThreadLocalRequestContext requestContext,
       MessageIdGenerator messageIdGenerator,
       AccountTemplateUtil accountTemplateUtil,
-      @Assisted AttentionSetSender sender,
+      EmailFactories emailFactories,
+      @Assisted AttentionSetChange attentionSetChange,
       @Assisted Context ctx,
       @Assisted Change change,
       @Assisted String reason,
@@ -85,8 +91,10 @@
     this.asyncSender =
         new AsyncSender(
             requestContext,
+            emailFactories,
             ctx.getUser(),
-            sender,
+            ctx.getProject(),
+            attentionSetChange,
             messageId,
             ctx.getNotify(change.getId()),
             attentionUserId,
@@ -107,8 +115,10 @@
    */
   private static class AsyncSender implements Runnable, RequestContext {
     private final ThreadLocalRequestContext requestContext;
+    private final EmailFactories emailFactories;
     private final CurrentUser user;
-    private final AttentionSetSender sender;
+    private final AttentionSetChange attentionSetChange;
+    private final Project.NameKey projectId;
     private final MessageIdGenerator.MessageId messageId;
     private final NotifyResolver.Result notify;
     private final Account.Id attentionUserId;
@@ -117,16 +127,20 @@
 
     AsyncSender(
         ThreadLocalRequestContext requestContext,
+        EmailFactories emailFactories,
         CurrentUser user,
-        AttentionSetSender sender,
+        Project.NameKey projectId,
+        AttentionSetChange attentionSetChange,
         MessageIdGenerator.MessageId messageId,
         NotifyResolver.Result notify,
         Account.Id attentionUserId,
         String reason,
         Change.Id changeId) {
       this.requestContext = requestContext;
+      this.emailFactories = emailFactories;
       this.user = user;
-      this.sender = sender;
+      this.projectId = projectId;
+      this.attentionSetChange = attentionSetChange;
       this.messageId = messageId;
       this.notify = notify;
       this.attentionUserId = attentionUserId;
@@ -138,18 +152,30 @@
     public void run() {
       RequestContext old = requestContext.setContext(this);
       try {
+        AttentionSetChangeEmailDecorator attentionSetChangeEmail =
+            emailFactories.createAttentionSetChangeEmail();
+        attentionSetChangeEmail.setAttentionSetChange(attentionSetChange);
+        attentionSetChangeEmail.setAttentionSetUser(attentionUserId);
+        attentionSetChangeEmail.setReason(reason);
+        ChangeEmail changeEmail =
+            emailFactories.createChangeEmail(projectId, changeId, attentionSetChangeEmail);
+        OutgoingEmail outgoingEmail =
+            emailFactories.createOutgoingEmail(
+                attentionSetChange.equals(AttentionSetChange.USER_ADDED)
+                    ? ATTENTION_SET_ADDED
+                    : ATTENTION_SET_REMOVED,
+                changeEmail);
+
         Optional<Account.Id> accountId =
             user.isIdentifiedUser()
                 ? Optional.of(user.asIdentifiedUser().getAccountId())
                 : Optional.empty();
         if (accountId.isPresent()) {
-          sender.setFrom(accountId.get());
+          outgoingEmail.setFrom(accountId.get());
         }
-        sender.setNotify(notify);
-        sender.setAttentionSetUser(attentionUserId);
-        sender.setReason(reason);
-        sender.setMessageId(messageId);
-        sender.send();
+        outgoingEmail.setNotify(notify);
+        outgoingEmail.setMessageId(messageId);
+        outgoingEmail.send();
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Cannot email update for change %s", changeId);
       } finally {
diff --git a/java/com/google/gerrit/server/util/ThreadLocalRequestContext.java b/java/com/google/gerrit/server/util/ThreadLocalRequestContext.java
index afd699c..da0364a 100644
--- a/java/com/google/gerrit/server/util/ThreadLocalRequestContext.java
+++ b/java/com/google/gerrit/server/util/ThreadLocalRequestContext.java
@@ -35,6 +35,7 @@
 public class ThreadLocalRequestContext {
   private static final String FALLBACK = "FALLBACK";
 
+  @SuppressWarnings("ProvidesMethodOutsideOfModule")
   public static Module module() {
     return new AbstractModule() {
       @Override
diff --git a/java/com/google/gerrit/server/validators/CustomKeyedValueValidationListener.java b/java/com/google/gerrit/server/validators/CustomKeyedValueValidationListener.java
new file mode 100644
index 0000000..f13330d
--- /dev/null
+++ b/java/com/google/gerrit/server/validators/CustomKeyedValueValidationListener.java
@@ -0,0 +1,36 @@
+// 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.validators;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/** Listener to provide validation of custom keyed values changes. */
+@ExtensionPoint
+public interface CustomKeyedValueValidationListener {
+  /**
+   * Invoked by Gerrit before custom keyed values are changed.
+   *
+   * @param change the change on which the custom keyed values are changed
+   * @param toAdd the custom keyed values to be added
+   * @param toRemove the custom keys to be removed
+   * @throws ValidationException if validation fails
+   */
+  void validateCustomKeyedValues(
+      Change change, ImmutableMap<String, String> toAdd, ImmutableSet<String> toRemove)
+      throws ValidationException;
+}
diff --git a/java/com/google/gerrit/server/version/BUILD b/java/com/google/gerrit/server/version/BUILD
new file mode 100644
index 0000000..c7f659c
--- /dev/null
+++ b/java/com/google/gerrit/server/version/BUILD
@@ -0,0 +1,17 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "version",
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/index/project",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/schema",
+        "@guice-library//jar",
+    ],
+)
diff --git a/java/com/google/gerrit/server/version/VersionInfoModule.java b/java/com/google/gerrit/server/version/VersionInfoModule.java
new file mode 100644
index 0000000..e6dea71
--- /dev/null
+++ b/java/com/google/gerrit/server/version/VersionInfoModule.java
@@ -0,0 +1,41 @@
+// 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.version;
+
+import com.google.gerrit.common.Version;
+import com.google.gerrit.extensions.common.VersionInfo;
+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 com.google.gerrit.server.schema.NoteDbSchemaVersions;
+import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+
+public class VersionInfoModule extends AbstractModule {
+  @Provides
+  @Singleton
+  public VersionInfo createVersionInfo() {
+    VersionInfo v = new VersionInfo();
+    v.gerritVersion = Version.getVersion();
+    v.noteDbVersion = NoteDbSchemaVersions.LATEST;
+    v.changeIndexVersion = ChangeSchemaDefinitions.INSTANCE.getLatest().getVersion();
+    v.accountIndexVersion = AccountSchemaDefinitions.INSTANCE.getLatest().getVersion();
+    v.projectIndexVersion = ProjectSchemaDefinitions.INSTANCE.getLatest().getVersion();
+    v.groupIndexVersion = GroupSchemaDefinitions.INSTANCE.getLatest().getVersion();
+    return v;
+  }
+}
diff --git a/java/com/google/gerrit/sshd/BUILD b/java/com/google/gerrit/sshd/BUILD
index af7078d..7a11131c 100644
--- a/java/com/google/gerrit/sshd/BUILD
+++ b/java/com/google/gerrit/sshd/BUILD
@@ -21,7 +21,9 @@
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
+        "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/server/version",
         "//java/com/google/gerrit/util/cli",
         "//java/com/google/gerrit/util/logging",
         "//lib:args4j",
diff --git a/java/com/google/gerrit/sshd/commands/ExternalIdCaseSensitivityMigrationCommand.java b/java/com/google/gerrit/sshd/commands/ExternalIdCaseSensitivityMigrationCommand.java
index aa147f0..85c7def 100644
--- a/java/com/google/gerrit/sshd/commands/ExternalIdCaseSensitivityMigrationCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ExternalIdCaseSensitivityMigrationCommand.java
@@ -16,7 +16,7 @@
 
 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.account.externalids.storage.notedb.OnlineExternalIdCaseSensivityMigrator;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
diff --git a/java/com/google/gerrit/sshd/commands/ExternalIdCommandsModule.java b/java/com/google/gerrit/sshd/commands/ExternalIdCommandsModule.java
index 57bf9e5..8b025d3 100644
--- a/java/com/google/gerrit/sshd/commands/ExternalIdCommandsModule.java
+++ b/java/com/google/gerrit/sshd/commands/ExternalIdCommandsModule.java
@@ -15,7 +15,7 @@
 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.account.externalids.storage.notedb.OnlineExternalIdCaseSensivityMigrator;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.sshd.CommandModule;
 import com.google.gerrit.sshd.Commands;
diff --git a/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java b/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
index e711d57..7660eeb 100644
--- a/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
@@ -16,7 +16,7 @@
 
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
-import com.google.gerrit.server.restapi.project.ListProjects;
+import com.google.gerrit.server.restapi.project.ListProjectsImpl;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gerrit.util.cli.Options;
@@ -28,7 +28,7 @@
     description = "List projects visible to the caller",
     runsAt = MASTER_OR_SLAVE)
 public class ListProjectsCommand extends SshCommand {
-  @Inject @Options public ListProjects impl;
+  @Inject @Options public ListProjectsImpl impl;
 
   @Override
   public void run() throws Exception {
diff --git a/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java b/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java
index 0119349b..d478a9d 100644
--- a/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java
+++ b/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
 import com.google.common.collect.Sets;
+import com.google.gerrit.server.plugins.PluginInstallException;
 import com.google.gerrit.sshd.CommandMetaData;
 import java.util.List;
 import org.kohsuke.args4j.Argument;
@@ -29,7 +30,11 @@
   @Override
   protected void doRun() throws UnloggedFailure {
     if (names != null && !names.isEmpty()) {
-      loader.disablePlugins(Sets.newHashSet(names));
+      try {
+        loader.disablePlugins(Sets.newHashSet(names));
+      } catch (PluginInstallException e) {
+        throw die(e);
+      }
     }
   }
 }
diff --git a/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index 143b060..e004940 100644
--- a/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -254,10 +254,7 @@
             ActionType.CHANGE_UPDATE,
             "applyReview",
             () -> {
-              gApi.changes()
-                  .id(patchSet.id().changeId().get())
-                  .revision(patchSet.commitId().name())
-                  .review(review);
+              getRevisionApi(patchSet).review(review);
               return null;
             })
         .call();
@@ -295,11 +292,11 @@
         AbandonInput input = new AbandonInput();
         input.message = Strings.emptyToNull(changeComment);
         applyReview(patchSet, review);
-        changeApi(patchSet).abandon(input);
+        getChangeApi(patchSet).abandon(input);
       } else if (restoreChange) {
         RestoreInput input = new RestoreInput();
         input.message = Strings.emptyToNull(changeComment);
-        changeApi(patchSet).restore(input);
+        getChangeApi(patchSet).restore(input);
         applyReview(patchSet, review);
       } else {
         applyReview(patchSet, review);
@@ -309,15 +306,15 @@
         MoveInput moveInput = new MoveInput();
         moveInput.destinationBranch = moveToBranch;
         moveInput.message = Strings.emptyToNull(changeComment);
-        changeApi(patchSet).move(moveInput);
+        getChangeApi(patchSet).move(moveInput);
       }
 
       if (rebaseChange) {
-        revisionApi(patchSet).rebase();
+        getRevisionApi(patchSet).rebase();
       }
 
       if (submitChange) {
-        revisionApi(patchSet).submit();
+        getRevisionApi(patchSet).submit();
       }
 
     } catch (IllegalStateException | RestApiException e) {
@@ -325,12 +322,19 @@
     }
   }
 
-  private ChangeApi changeApi(PatchSet patchSet) throws RestApiException {
-    return gApi.changes().id(patchSet.id().changeId().get());
+  private ChangeApi getChangeApi(PatchSet patchSet) throws RestApiException {
+    if (projectState != null) {
+      return gApi.changes().id(projectState.getName(), patchSet.id().changeId().get());
+    }
+    /* Since we didn't get a project from the CLI we have to use the ambiguous
+     * Changes#id(String) that may fail to identify one single change and throw
+     * an exception.
+     */
+    return gApi.changes().id(String.valueOf(patchSet.id().changeId().get()));
   }
 
-  private RevisionApi revisionApi(PatchSet patchSet) throws RestApiException {
-    return changeApi(patchSet).revision(patchSet.commitId().name());
+  private RevisionApi getRevisionApi(PatchSet patchSet) throws RestApiException {
+    return getChangeApi(patchSet).revision(patchSet.commitId().name());
   }
 
   @Override
diff --git a/java/com/google/gerrit/sshd/commands/SequenceSetCommand.java b/java/com/google/gerrit/sshd/commands/SequenceSetCommand.java
index 197d61c..3ec34bc 100644
--- a/java/com/google/gerrit/sshd/commands/SequenceSetCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SequenceSetCommand.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/sshd/commands/SequenceShowCommand.java b/java/com/google/gerrit/sshd/commands/SequenceShowCommand.java
index 490c7ca..e9058b4 100644
--- a/java/com/google/gerrit/sshd/commands/SequenceShowCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SequenceShowCommand.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/sshd/commands/ShowCaches.java b/java/com/google/gerrit/sshd/commands/ShowCaches.java
index 5b89228..5d1a402 100644
--- a/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -74,9 +74,6 @@
     public void stop() {}
   }
 
-  @Option(name = "--gc", usage = "perform Java GC before printing memory stats")
-  private boolean gc;
-
   @Option(name = "--show-jvm", usage = "show details about the JVM")
   private boolean showJVM;
 
@@ -141,8 +138,7 @@
       if (showJvm) {
         sshSummary();
 
-        SummaryInfo summary =
-            getSummary.setGc(gc).setJvm(showJVM).apply(new ConfigResource()).value();
+        SummaryInfo summary = getSummary.setJvm(showJVM).apply(new ConfigResource()).value();
         taskSummary(summary.taskSummary);
         memSummary(summary.memSummary);
         threadSummary(summary.threadSummary);
diff --git a/java/com/google/gerrit/sshd/commands/VersionCommand.java b/java/com/google/gerrit/sshd/commands/VersionCommand.java
index f8771fb..c274b3d 100644
--- a/java/com/google/gerrit/sshd/commands/VersionCommand.java
+++ b/java/com/google/gerrit/sshd/commands/VersionCommand.java
@@ -16,21 +16,40 @@
 
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
-import com.google.gerrit.common.Version;
+import com.google.gerrit.extensions.common.VersionInfo;
+import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import org.kohsuke.args4j.Option;
 
 @CommandMetaData(name = "version", description = "Display gerrit version", runsAt = MASTER_OR_SLAVE)
 final class VersionCommand extends SshCommand {
 
+  @Option(
+      name = "--verbose",
+      aliases = {"-v"},
+      usage = "verbose version info")
+  private boolean verbose;
+
+  @Option(name = "--json", usage = "json output format, assumes verbose output")
+  private boolean json;
+
+  @Inject private VersionInfo versionInfo;
+
   @Override
   protected void run() throws Failure {
     enableGracefulStop();
-    String v = Version.getVersion();
-    if (v == null) {
+    if (versionInfo.gerritVersion == null) {
       throw new Failure(1, "fatal: version unavailable");
     }
 
-    stdout.println("gerrit version " + v);
+    if (json) {
+      stdout.println(OutputFormat.JSON.newGson().toJson(versionInfo));
+    } else if (verbose) {
+      stdout.print(versionInfo.verbose());
+    } else {
+      stdout.print(versionInfo.compact());
+    }
   }
 }
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index 81a6443..95c9b13 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -8,10 +8,12 @@
         exclude = [
             "AssertableExecutorService.java",
             "TestActionRefUpdateContext.java",
+            "GerritJUnit.java",
         ],
     ),
     visibility = ["//visibility:public"],
     exports = [
+        ":gerrit-junit",
         "//lib:junit",
         "//lib/mockito",
     ],
@@ -44,6 +46,7 @@
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/server/util/time",
         "//java/com/google/gerrit/testing:test-ref-update-context",
+        "//javatests/com/google/gerrit/util/http/testutil",
         "//lib:guava",
         "//lib:h2",
         "//lib:jgit",
@@ -61,6 +64,12 @@
 )
 
 java_library(
+    name = "gerrit-junit",
+    srcs = ["GerritJUnit.java"],
+    visibility = ["//visibility:public"],
+)
+
+java_library(
     # This can't be part of gerrit-test-util because of https://github.com/google/guava/issues/2837
     name = "assertable-executor",
     testonly = True,
diff --git a/java/com/google/gerrit/testing/FakeAccountPatchReviewStore.java b/java/com/google/gerrit/testing/FakeAccountPatchReviewStore.java
index 1533aeb..0c612f0 100644
--- a/java/com/google/gerrit/testing/FakeAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/testing/FakeAccountPatchReviewStore.java
@@ -117,6 +117,19 @@
   }
 
   @Override
+  public void clearReviewedBy(Account.Id accountId) {
+    synchronized (store) {
+      List<Entity> toRemove = new ArrayList<>();
+      for (Entity entity : store) {
+        if (entity.accountId().equals(accountId)) {
+          toRemove.add(entity);
+        }
+      }
+      store.removeAll(toRemove);
+    }
+  }
+
+  @Override
   public Optional<PatchSetWithReviewedFiles> findReviewed(PatchSet.Id psId, Account.Id accountId) {
     synchronized (store) {
       int matchedPsNumber = -1;
diff --git a/java/com/google/gerrit/testing/FakeEmailSender.java b/java/com/google/gerrit/testing/FakeEmailSender.java
index 918a622..a2ebc88 100644
--- a/java/com/google/gerrit/testing/FakeEmailSender.java
+++ b/java/com/google/gerrit/testing/FakeEmailSender.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.mail.send.EmailResource;
 import com.google.gerrit.server.mail.send.EmailSender;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
@@ -63,9 +64,15 @@
         Collection<Address> rcpt,
         Map<String, EmailHeader> headers,
         String body,
-        String htmlBody) {
+        String htmlBody,
+        Collection<EmailResource> htmlResources) {
       return new AutoValue_FakeEmailSender_Message(
-          from, ImmutableList.copyOf(rcpt), ImmutableMap.copyOf(headers), body, htmlBody);
+          from,
+          ImmutableList.copyOf(rcpt),
+          ImmutableMap.copyOf(headers),
+          body,
+          htmlBody,
+          ImmutableList.copyOf(htmlResources));
     }
 
     public abstract Address from();
@@ -78,6 +85,8 @@
 
     @Nullable
     public abstract String htmlBody();
+
+    public abstract ImmutableList<EmailResource> htmlResources();
   }
 
   private final WorkQueue workQueue;
@@ -105,7 +114,7 @@
   public void send(
       Address from, Collection<Address> rcpt, Map<String, EmailHeader> headers, String body)
       throws EmailException {
-    send(from, rcpt, headers, body, null);
+    send(from, rcpt, headers, body, null, ImmutableList.of());
   }
 
   @Override
@@ -116,7 +125,19 @@
       String body,
       String htmlBody)
       throws EmailException {
-    messages.add(Message.create(from, rcpt, headers, body, htmlBody));
+    messages.add(Message.create(from, rcpt, headers, body, htmlBody, ImmutableList.of()));
+  }
+
+  @Override
+  public void send(
+      Address from,
+      Collection<Address> rcpt,
+      Map<String, EmailHeader> headers,
+      String body,
+      String htmlBody,
+      Collection<EmailResource> htmlResources)
+      throws EmailException {
+    messages.add(Message.create(from, rcpt, headers, body, htmlBody, htmlResources));
   }
 
   public void clear() {
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 0002030..3f0f8b7 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.util.concurrent.MoreExecutors.newDirectExecutorService;
+import static com.google.gerrit.server.Sequence.LightweightGroups;
 import static com.google.inject.Scopes.SINGLETON;
 
 import com.google.common.base.Strings;
@@ -44,7 +45,11 @@
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.LibModuleType;
 import com.google.gerrit.server.PluginUser;
+import com.google.gerrit.server.Sequence;
+import com.google.gerrit.server.account.AccountCacheImpl;
 import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.account.storage.notedb.AccountNoteDbReadStorageModule;
+import com.google.gerrit.server.account.storage.notedb.AccountNoteDbWriteStorageModule;
 import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.api.PluginApiModule;
 import com.google.gerrit.server.api.projects.ProjectQueryBuilderModule;
@@ -85,7 +90,6 @@
 import com.google.gerrit.server.git.GarbageCollection;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.PerThreadRequestScope;
-import com.google.gerrit.server.git.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;
@@ -95,7 +99,10 @@
 import com.google.gerrit.server.index.group.AllGroupsIndexer;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
+import com.google.gerrit.server.mail.EmailModule;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier.SignedTokenEmailTokenVerifierModule;
+import com.google.gerrit.server.notedb.RepoSequence.DisabledGitRefUpdatedRepoGroupsSequenceProvider;
+import com.google.gerrit.server.notedb.RepoSequence.RepoSequenceModule;
 import com.google.gerrit.server.patch.DiffExecutor;
 import com.google.gerrit.server.permissions.DefaultPermissionBackendModule;
 import com.google.gerrit.server.plugins.ServerInformationImpl;
@@ -143,7 +150,6 @@
     cfg.setString(
         "accountPatchReviewDb", null, "url", JdbcAccountPatchReviewStore.TEST_IN_MEMORY_URL);
     cfg.setEnum("auth", null, "type", AuthType.DEVELOPMENT_BECOME_ANY_ACCOUNT);
-    cfg.setString("gerrit", null, "allProjects", "Test-Projects");
     cfg.setString("gerrit", null, "basePath", "git");
     cfg.setString("gerrit", null, "canonicalWebUrl", "http://test/");
     cfg.setString("user", null, "name", "Gerrit Code Review");
@@ -151,6 +157,7 @@
     cfg.unset("cache", null, "directory");
     cfg.setBoolean("index", "lucene", "testInmemory", true);
     cfg.setInt("sendemail", null, "threadPoolSize", 0);
+    cfg.setInt("execution", null, "fanOutThreadPoolSize", 0);
     cfg.setBoolean("receive", null, "enableSignedPush", false);
     cfg.setString("receive", null, "certNonceSeed", "sekret");
   }
@@ -190,7 +197,12 @@
             });
     bind(GerritRuntime.class).toInstance(GerritRuntime.DAEMON);
     bind(MetricMaker.class).to(DisabledMetricMaker.class);
+
+    install(cfgInjector.getInstance(AccountCacheImpl.AccountCacheModule.class));
     install(cfgInjector.getInstance(GerritGlobalModule.class));
+    install(new AccountNoteDbWriteStorageModule());
+    install(new AccountNoteDbReadStorageModule());
+    install(new RepoSequenceModule());
 
     AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
     install(new AuthModule(authConfig));
@@ -249,6 +261,7 @@
         });
     install(new DefaultMemoryCacheModule());
     install(new H2CacheModule());
+    install(new EmailModule());
     install(new FakeEmailSenderModule());
     install(new SignedTokenEmailTokenVerifierModule());
     install(new GpgModule(cfg));
@@ -304,6 +317,9 @@
           .toProvider(AnonymousCowardNameProvider.class);
 
       bind(GroupIndexCollection.class);
+      bind(Sequence.class)
+          .annotatedWith(LightweightGroups.class)
+          .toProvider(DisabledGitRefUpdatedRepoGroupsSequenceProvider.class);
       bind(SchemaCreator.class).to(SchemaCreatorImpl.class);
     }
 
@@ -342,8 +358,8 @@
   @Provides
   @Singleton
   @FanOutExecutor
-  public ExecutorService createFanOutExecutor(WorkQueue queues) {
-    return queues.createQueue(2, "FanOut");
+  public ExecutorService createFanOutExecutor() {
+    return newDirectExecutorService();
   }
 
   @Provides
diff --git a/java/com/google/gerrit/testing/RefUpdateContextCollector.java b/java/com/google/gerrit/testing/RefUpdateContextCollector.java
index 88232d2..2d28af3 100644
--- a/java/com/google/gerrit/testing/RefUpdateContextCollector.java
+++ b/java/com/google/gerrit/testing/RefUpdateContextCollector.java
@@ -86,6 +86,17 @@
         .collect(toImmutableList());
   }
 
+  public ImmutableList<Entry<String, ImmutableList<RefUpdateContext>>> getContextsByUpdateType(
+      RefUpdateType refUpdateType) {
+    return touchedRefsWithContexts.stream()
+        .filter(
+            entry ->
+                entry.getValue().stream()
+                    .map(RefUpdateContext::getUpdateType)
+                    .anyMatch(refUpdateType::equals))
+        .collect(toImmutableList());
+  }
+
   public void clear() {
     touchedRefsWithContexts.clear();
   }
diff --git a/java/com/google/gerrit/testing/TestActionRefUpdateContext.java b/java/com/google/gerrit/testing/TestActionRefUpdateContext.java
index 23ec9aa..e3f6dcf 100644
--- a/java/com/google/gerrit/testing/TestActionRefUpdateContext.java
+++ b/java/com/google/gerrit/testing/TestActionRefUpdateContext.java
@@ -16,6 +16,7 @@
 
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.server.update.context.RefUpdateContext;
+import java.util.Optional;
 
 /**
  * Marks ref updates as a test actions.
@@ -62,6 +63,10 @@
     }
   }
 
+  public TestActionRefUpdateContext() {
+    super(Optional.empty());
+  }
+
   public interface CallableWithException<V, E extends Exception> {
     V call() throws E;
   }
diff --git a/java/gerrit/AbstractCommitUserIdentityPredicate.java b/java/gerrit/AbstractCommitUserIdentityPredicate.java
index 51c4a3b..8d7e513 100644
--- a/java/gerrit/AbstractCommitUserIdentityPredicate.java
+++ b/java/gerrit/AbstractCommitUserIdentityPredicate.java
@@ -17,7 +17,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.Emails;
-import com.google.gerrit.server.rules.PrologEnvironment;
+import com.google.gerrit.server.rules.prolog.PrologEnvironment;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.exceptions.SystemException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
diff --git a/java/gerrit/BUILD b/java/gerrit/BUILD
index fea2696..6923c3d 100644
--- a/java/gerrit/BUILD
+++ b/java/gerrit/BUILD
@@ -9,6 +9,7 @@
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/rules/prolog",
         "//lib:jgit",
         "//lib/flogger:api",
         "//lib/prolog:runtime",
diff --git a/java/gerrit/PRED__load_commit_labels_1.java b/java/gerrit/PRED__load_commit_labels_1.java
index 9a656b8..38af608 100644
--- a/java/gerrit/PRED__load_commit_labels_1.java
+++ b/java/gerrit/PRED__load_commit_labels_1.java
@@ -6,7 +6,7 @@
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.ListTerm;
diff --git a/java/gerrit/PRED_change_branch_1.java b/java/gerrit/PRED_change_branch_1.java
index 62744f7..2bac305 100644
--- a/java/gerrit/PRED_change_branch_1.java
+++ b/java/gerrit/PRED_change_branch_1.java
@@ -15,7 +15,7 @@
 package gerrit;
 
 import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Predicate;
diff --git a/java/gerrit/PRED_change_owner_1.java b/java/gerrit/PRED_change_owner_1.java
index f6fbb80..6fcda6c 100644
--- a/java/gerrit/PRED_change_owner_1.java
+++ b/java/gerrit/PRED_change_owner_1.java
@@ -15,7 +15,7 @@
 package gerrit;
 
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
diff --git a/java/gerrit/PRED_change_project_1.java b/java/gerrit/PRED_change_project_1.java
index b2ef109..f9c7822 100644
--- a/java/gerrit/PRED_change_project_1.java
+++ b/java/gerrit/PRED_change_project_1.java
@@ -15,7 +15,7 @@
 package gerrit;
 
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Predicate;
diff --git a/java/gerrit/PRED_change_topic_1.java b/java/gerrit/PRED_change_topic_1.java
index f0175ef..cd524e3 100644
--- a/java/gerrit/PRED_change_topic_1.java
+++ b/java/gerrit/PRED_change_topic_1.java
@@ -15,7 +15,7 @@
 package gerrit;
 
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Predicate;
diff --git a/java/gerrit/PRED_commit_author_3.java b/java/gerrit/PRED_commit_author_3.java
index 3381344..5606a09 100644
--- a/java/gerrit/PRED_commit_author_3.java
+++ b/java/gerrit/PRED_commit_author_3.java
@@ -14,7 +14,7 @@
 
 package gerrit;
 
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Prolog;
diff --git a/java/gerrit/PRED_commit_committer_3.java b/java/gerrit/PRED_commit_committer_3.java
index 1757336..15ef320 100644
--- a/java/gerrit/PRED_commit_committer_3.java
+++ b/java/gerrit/PRED_commit_committer_3.java
@@ -14,7 +14,7 @@
 
 package gerrit;
 
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Prolog;
diff --git a/java/gerrit/PRED_commit_delta_4.java b/java/gerrit/PRED_commit_delta_4.java
index 502b15b..1d6c1c0 100644
--- a/java/gerrit/PRED_commit_delta_4.java
+++ b/java/gerrit/PRED_commit_delta_4.java
@@ -15,7 +15,7 @@
 package gerrit;
 
 import com.google.gerrit.server.patch.PatchListEntry;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
 import com.googlecode.prolog_cafe.exceptions.JavaException;
 import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
diff --git a/java/gerrit/PRED_commit_edits_2.java b/java/gerrit/PRED_commit_edits_2.java
index e598ec9..52d7f63 100644
--- a/java/gerrit/PRED_commit_edits_2.java
+++ b/java/gerrit/PRED_commit_edits_2.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.server.patch.Text;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.patch.filediff.TaggedEdit;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
 import com.googlecode.prolog_cafe.exceptions.JavaException;
 import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
diff --git a/java/gerrit/PRED_commit_message_1.java b/java/gerrit/PRED_commit_message_1.java
index 3485af6..57a65c1 100644
--- a/java/gerrit/PRED_commit_message_1.java
+++ b/java/gerrit/PRED_commit_message_1.java
@@ -14,7 +14,7 @@
 
 package gerrit;
 
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Predicate;
diff --git a/java/gerrit/PRED_commit_parent_count_1.java b/java/gerrit/PRED_commit_parent_count_1.java
index 81589dd..46a5e4d 100644
--- a/java/gerrit/PRED_commit_parent_count_1.java
+++ b/java/gerrit/PRED_commit_parent_count_1.java
@@ -14,7 +14,7 @@
 
 package gerrit;
 
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
diff --git a/java/gerrit/PRED_commit_stats_3.java b/java/gerrit/PRED_commit_stats_3.java
index 82fad3d..379dc18 100644
--- a/java/gerrit/PRED_commit_stats_3.java
+++ b/java/gerrit/PRED_commit_stats_3.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
diff --git a/java/gerrit/PRED_files_1.java b/java/gerrit/PRED_files_1.java
index dbf96da..e97b8ba 100644
--- a/java/gerrit/PRED_files_1.java
+++ b/java/gerrit/PRED_files_1.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.server.patch.FilePathAdapter;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.ListTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
diff --git a/java/gerrit/PRED_get_legacy_label_types_1.java b/java/gerrit/PRED_get_legacy_label_types_1.java
index dfed17b..52247c8 100644
--- a/java/gerrit/PRED_get_legacy_label_types_1.java
+++ b/java/gerrit/PRED_get_legacy_label_types_1.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.ListTerm;
diff --git a/java/gerrit/PRED_project_default_submit_type_1.java b/java/gerrit/PRED_project_default_submit_type_1.java
index 77a0261..d99d5f1 100644
--- a/java/gerrit/PRED_project_default_submit_type_1.java
+++ b/java/gerrit/PRED_project_default_submit_type_1.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Predicate;
diff --git a/java/gerrit/PRED_pure_revert_1.java b/java/gerrit/PRED_pure_revert_1.java
index 19e7b68..b13fbe4 100644
--- a/java/gerrit/PRED_pure_revert_1.java
+++ b/java/gerrit/PRED_pure_revert_1.java
@@ -14,7 +14,7 @@
 
 package gerrit;
 
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
diff --git a/java/gerrit/PRED_unresolved_comments_count_1.java b/java/gerrit/PRED_unresolved_comments_count_1.java
index 9a1fcca..c932495 100644
--- a/java/gerrit/PRED_unresolved_comments_count_1.java
+++ b/java/gerrit/PRED_unresolved_comments_count_1.java
@@ -14,7 +14,7 @@
 
 package gerrit;
 
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
diff --git a/java/gerrit/PRED_uploader_1.java b/java/gerrit/PRED_uploader_1.java
index 89e367e..3ab961f 100644
--- a/java/gerrit/PRED_uploader_1.java
+++ b/java/gerrit/PRED_uploader_1.java
@@ -17,7 +17,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index dd04200..f3bb15b 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -32,7 +32,6 @@
 import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithExpiration;
 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;
@@ -65,6 +64,7 @@
 import com.google.common.truth.Correspondence;
 import com.google.common.util.concurrent.AtomicLongMap;
 import com.google.common.util.concurrent.Runnables;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AccountIndexedCounter;
 import com.google.gerrit.acceptance.ExtensionRegistry;
@@ -83,6 +83,8 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.acceptance.testsuite.request.SshSessionFactory;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Account;
@@ -131,6 +133,8 @@
 import com.google.gerrit.gpg.testing.TestKey;
 import com.google.gerrit.httpd.CacheBasedWebSession;
 import com.google.gerrit.server.ExceptionHook;
+import com.google.gerrit.server.Sequence;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountProperties;
@@ -141,17 +145,18 @@
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
-import com.google.gerrit.server.account.externalids.ExternalIdNotes;
-import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdFactoryNoteDbImpl;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNotes;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdsNoteDbImpl;
+import com.google.gerrit.server.account.storage.notedb.AccountsUpdateNoteDbImpl;
+import com.google.gerrit.server.change.AccountPatchReviewStore;
 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;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.RefPattern;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
@@ -175,6 +180,8 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -228,12 +235,16 @@
     return cfg;
   }
 
+  @Inject protected ProjectOperations projectOperations;
+  @Inject protected Emails emails;
+
+  @Inject protected GroupOperations groupOperations;
+
   @Inject private @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider;
   @Inject private AccountIndexer accountIndexer;
   @Inject private ExternalIdNotes.Factory extIdNotesFactory;
-  @Inject private ExternalIds externalIds;
+  @Inject private ExternalIdsNoteDbImpl externalIds;
   @Inject private GitReferenceUpdated gitReferenceUpdated;
-  @Inject private ProjectOperations projectOperations;
   @Inject private Provider<InternalAccountQuery> accountQueryProvider;
   @Inject private Provider<MetaDataUpdate.InternalFactory> metaDataUpdateInternalFactory;
   @Inject private Provider<PublicKeyStore> publicKeyStoreProvider;
@@ -245,15 +256,12 @@
   @Inject private ExtensionRegistry extensionRegistry;
   @Inject private PluginSetContext<ExceptionHook> exceptionHooks;
   @Inject private ExternalIdKeyFactory externalIdKeyFactory;
-  @Inject private ExternalIdFactory externalIdFactory;
+  @Inject private ExternalIdFactoryNoteDbImpl externalIdFactory;
   @Inject private AuthConfig authConfig;
   @Inject private AccountControl.Factory accountControlFactory;
-
-  @Inject protected Emails emails;
-
   @Inject private AccountOperations accountOperations;
 
-  @Inject protected GroupOperations groupOperations;
+  @Inject private AccountPatchReviewStore accountPatchReviewStore;
 
   private BasicCookieStore httpCookieStore;
   private CloseableHttpClient httpclient;
@@ -325,7 +333,7 @@
       refUpdateCounter.assertRefUpdateFor(
           RefUpdateCounter.projectRef(allUsers, RefNames.refsUsers(accountId)),
           RefUpdateCounter.projectRef(allUsers, RefNames.REFS_EXTERNAL_IDS),
-          RefUpdateCounter.projectRef(allUsers, RefNames.REFS_SEQUENCES + Sequences.NAME_ACCOUNTS));
+          RefUpdateCounter.projectRef(allUsers, RefNames.REFS_SEQUENCES + Sequence.NAME_ACCOUNTS));
     }
   }
 
@@ -344,7 +352,8 @@
     assertThrows(ResourceNotFoundException.class, () -> gApi.accounts().id(input.username).get());
   }
 
-  private Account.Id createByAccountCreator(int expectedAccountReindexCalls) throws Exception {
+  @UsedAt(UsedAt.Project.GOOGLE)
+  protected Account.Id createByAccountCreator(int expectedAccountReindexCalls) throws Exception {
     AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
@@ -579,25 +588,27 @@
     AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      int id = gApi.accounts().id("user").get()._accountId;
-      assertThat(gApi.accounts().id("user").getActive()).isTrue();
-      gApi.accounts().id("user").setActive(false);
+      int id = gApi.accounts().id(user.username()).get()._accountId;
+      assertThat(gApi.accounts().id(user.username()).getActive()).isTrue();
+      gApi.accounts().id(user.username()).setActive(false);
       accountIndexedCounter.assertReindexOf(user);
 
       // Inactive users may only be resolved by ID.
       ResourceNotFoundException thrown =
-          assertThrows(ResourceNotFoundException.class, () -> gApi.accounts().id("user"));
+          assertThrows(ResourceNotFoundException.class, () -> gApi.accounts().id(user.username()));
       assertThat(thrown)
           .hasMessageThat()
           .isEqualTo(
-              "Account 'user' only matches inactive accounts. To use an inactive account, retry"
+              "Account '"
+                  + user.username()
+                  + "' only matches inactive accounts. To use an inactive account, retry"
                   + " with one of the following exact account IDs:\n"
                   + id
                   + ": User1 <user1@example.com>");
       assertThat(gApi.accounts().id(id).getActive()).isFalse();
 
       gApi.accounts().id(id).setActive(true);
-      assertThat(gApi.accounts().id("user").getActive()).isTrue();
+      assertThat(gApi.accounts().id(user.username()).getActive()).isTrue();
       accountIndexedCounter.assertReindexOf(user);
     }
   }
@@ -767,9 +778,9 @@
 
   @Test
   public void deactivateNotActive() throws Exception {
-    int id = gApi.accounts().id("user").get()._accountId;
-    assertThat(gApi.accounts().id("user").getActive()).isTrue();
-    gApi.accounts().id("user").setActive(false);
+    int id = gApi.accounts().id(user.username()).get()._accountId;
+    assertThat(gApi.accounts().id(user.username()).getActive()).isTrue();
+    gApi.accounts().id(user.username()).setActive(false);
     assertThat(gApi.accounts().id(id).getActive()).isFalse();
     ResourceConflictException thrown =
         assertThrows(
@@ -791,7 +802,6 @@
       gApi.accounts().self().starChange(triplet);
       ChangeInfo change = info(triplet);
       assertThat(change.starred).isTrue();
-      assertThat(change.stars).contains(DEFAULT_LABEL);
       refUpdateCounter.assertRefUpdateFor(
           RefUpdateCounter.projectRef(
               allUsers, RefNames.refsStarredChanges(Change.id(change._number), admin.id())));
@@ -799,7 +809,6 @@
       gApi.accounts().self().unstarChange(triplet);
       change = info(triplet);
       assertThat(change.starred).isNull();
-      assertThat(change.stars).isNull();
       refUpdateCounter.assertRefUpdateFor(
           RefUpdateCounter.projectRef(
               allUsers, RefNames.refsStarredChanges(Change.id(change._number), admin.id())));
@@ -843,7 +852,7 @@
     List<Message> messages2 = sender.getMessages();
     assertThat(messages2).hasSize(1);
     Message message2 = messages2.get(0);
-    assertThat(message2.rcpt()).containsExactly(user.getNameEmail(), user2.getNameEmail());
+    assertThat(message2.rcpt()).containsExactly(user2.getNameEmail());
     assertMailReplyTo(message, admin.email());
 
     sender.clear();
@@ -888,7 +897,7 @@
     List<Message> messages2 = sender.getMessages();
     assertThat(messages2).hasSize(1);
     Message message2 = messages2.get(0);
-    assertThat(message2.rcpt()).containsExactly(user.getNameEmail(), user2.getNameEmail());
+    assertThat(message2.rcpt()).containsExactly(user2.getNameEmail());
     assertMailReplyTo(message2, admin.email());
 
     sender.clear();
@@ -1186,7 +1195,7 @@
     AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
-      String previous = gApi.accounts().self().get().email;
+      ImmutableSet<String> previous = getEmails();
       String email = "foo.bar.baz@example.com";
       EmailInput input = new EmailInput();
       input.email = email;
@@ -1206,7 +1215,7 @@
       accountIndexedCounter.assertReindexOf(admin);
 
       requestScopeOperations.resetCurrentApiUser();
-      assertThat(getEmails()).containsExactly(previous);
+      assertThat(getEmails()).isEqualTo(previous);
       assertThat(gApi.accounts().self().get().email).isNull();
     }
   }
@@ -1260,13 +1269,13 @@
           .containsAtLeast(extId1, extId2);
 
       requestScopeOperations.resetCurrentApiUser();
-      assertThat(getEmails()).contains(email);
+      assertThat(getExtIdsEmail()).contains(email);
 
       gApi.accounts().self().deleteEmail(email);
       accountIndexedCounter.assertReindexOf(admin);
 
       requestScopeOperations.resetCurrentApiUser();
-      assertThat(getEmails()).doesNotContain(email);
+      assertThat(getExtIdsEmail()).doesNotContain(email);
       assertThat(
               gApi.accounts().self().getExternalIds().stream()
                   .map(e -> e.identity)
@@ -1290,9 +1299,13 @@
                     externalIdFactory.createWithEmail(
                         externalIdKeyFactory.parse(ldapExternalId), admin.id(), ldapEmail)));
     assertThat(
-            gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
+            gApi.accounts().self().getExternalIds().stream()
+                .map(e -> e.identity)
+                .collect(toImmutableSet()))
         .contains(ldapExternalId);
 
+    assertThat(getExtIdsEmail()).contains(ldapEmail);
+
     requestScopeOperations.resetCurrentApiUser();
     assertThat(getEmails()).contains(ldapEmail);
 
@@ -1334,12 +1347,12 @@
         .containsAtLeast(ldapExternalId, nonLdapExternalId);
 
     requestScopeOperations.resetCurrentApiUser();
-    assertThat(getEmails()).containsAtLeast(ldapEmail, nonLdapEMail);
+    assertThat(getExtIdsEmail()).containsAtLeast(ldapEmail, nonLdapEMail);
 
     gApi.accounts().self().deleteEmail(nonLdapEMail);
 
     requestScopeOperations.resetCurrentApiUser();
-    assertThat(getEmails()).doesNotContain(nonLdapEMail);
+    assertThat(getExtIdsEmail()).doesNotContain(nonLdapEMail);
     assertThat(
             gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
         .contains(ldapExternalId);
@@ -2087,7 +2100,7 @@
     List<GroupInfo> groups = gApi.accounts().id(admin.username()).getGroups();
     assertThat(groups)
         .comparingElementsUsing(getGroupToNameCorrespondence())
-        .containsExactly("Anonymous Users", "Registered Users", "Administrators");
+        .containsAtLeast("Anonymous Users", "Registered Users", "Administrators");
   }
 
   @Test
@@ -2164,26 +2177,8 @@
     String status = "happy";
     String fullName = "Foo";
     AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
-    PersonIdent ident = serverIdent.get();
     AccountsUpdate update =
-        new AccountsUpdate(
-            repoManager,
-            gitReferenceUpdated,
-            Optional.empty(),
-            allUsers,
-            externalIds,
-            metaDataUpdateInternalFactory,
-            new RetryHelper(
-                cfg,
-                retryMetrics,
-                null,
-                null,
-                null,
-                exceptionHooks,
-                r -> r.withBlockStrategy(noSleepBlockStrategy)),
-            extIdNotesFactory,
-            ident,
-            ident,
+        getAccountsUpdate(
             () -> {
               if (!doneBgUpdate.getAndSet(true)) {
                 try {
@@ -2220,28 +2215,8 @@
     List<String> status = ImmutableList.of("foo", "bar", "baz");
     String fullName = "Foo";
     AtomicInteger bgCounter = new AtomicInteger(0);
-    PersonIdent ident = serverIdent.get();
     AccountsUpdate update =
-        new AccountsUpdate(
-            repoManager,
-            gitReferenceUpdated,
-            Optional.empty(),
-            allUsers,
-            externalIds,
-            metaDataUpdateInternalFactory,
-            new RetryHelper(
-                cfg,
-                retryMetrics,
-                null,
-                null,
-                null,
-                exceptionHooks,
-                r ->
-                    r.withStopStrategy(StopStrategies.stopAfterAttempt(status.size()))
-                        .withBlockStrategy(noSleepBlockStrategy)),
-            extIdNotesFactory,
-            ident,
-            ident,
+        getAccountsUpdate(
             () -> {
               try {
                 accountsUpdateProvider
@@ -2254,7 +2229,17 @@
                 // Ignore, the expected exception is asserted later
               }
             },
-            Runnables.doNothing());
+            Runnables.doNothing(),
+            new RetryHelper(
+                cfg,
+                retryMetrics,
+                null,
+                null,
+                null,
+                exceptionHooks,
+                r ->
+                    r.withStopStrategy(StopStrategies.stopAfterAttempt(status.size()))
+                        .withBlockStrategy(noSleepBlockStrategy)));
     assertThat(bgCounter.get()).isEqualTo(0);
     AccountInfo accountInfo = gApi.accounts().id(admin.id().get()).get();
     assertThat(accountInfo.status).isNull();
@@ -2280,26 +2265,8 @@
 
     AtomicInteger bgCounterA1 = new AtomicInteger(0);
     AtomicInteger bgCounterA2 = new AtomicInteger(0);
-    PersonIdent ident = serverIdent.get();
     AccountsUpdate update =
-        new AccountsUpdate(
-            repoManager,
-            gitReferenceUpdated,
-            Optional.empty(),
-            allUsers,
-            externalIds,
-            metaDataUpdateInternalFactory,
-            new RetryHelper(
-                cfg,
-                retryMetrics,
-                null,
-                null,
-                null,
-                exceptionHooks,
-                r -> r.withBlockStrategy(noSleepBlockStrategy)),
-            extIdNotesFactory,
-            ident,
-            ident,
+        getAccountsUpdate(
             Runnables.doNothing(),
             () -> {
               try {
@@ -2354,27 +2321,9 @@
 
     AtomicInteger bgCounterA1 = new AtomicInteger(0);
     AtomicInteger bgCounterA2 = new AtomicInteger(0);
-    PersonIdent ident = serverIdent.get();
     ExternalId extIdA2 = externalIdFactory.create("foo", "A-2", accountId);
     AccountsUpdate update =
-        new AccountsUpdate(
-            repoManager,
-            gitReferenceUpdated,
-            Optional.empty(),
-            allUsers,
-            externalIds,
-            metaDataUpdateInternalFactory,
-            new RetryHelper(
-                cfg,
-                retryMetrics,
-                null,
-                null,
-                null,
-                exceptionHooks,
-                r -> r.withBlockStrategy(noSleepBlockStrategy)),
-            extIdNotesFactory,
-            ident,
-            ident,
+        getAccountsUpdate(
             Runnables.doNothing(),
             () -> {
               try {
@@ -3042,7 +2991,7 @@
 
     ExternalId externalId =
         externalIdFactory.createWithEmail(
-            SCHEME_USERNAME, "admin", admin.id(), "secondary@example.com");
+            SCHEME_USERNAME, admin.username(), admin.id(), "secondary@example.com");
     accountsUpdateProvider
         .get()
         .update(
@@ -3050,7 +2999,9 @@
             admin.id(),
             (a, u) ->
                 u.replaceExternalId(
-                    externalIds.get(externalIdKeyFactory.create(SCHEME_USERNAME, "admin")).get(),
+                    externalIds
+                        .get(externalIdKeyFactory.create(SCHEME_USERNAME, admin.username()))
+                        .get(),
                     externalId));
     assertExternalIds(admin.id(), ImmutableSet.of("mailto:admin@example.com", "username:admin"));
 
@@ -3155,8 +3106,7 @@
 
     // 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)) {
+    try (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.
@@ -3211,8 +3161,8 @@
 
     // 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)) {
+
+    try (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(
@@ -3224,6 +3174,225 @@
     }
   }
 
+  @Test
+  public void deleteAccount_deletesAccountIdentifiers() throws Exception {
+    TestAccount deleted = accountCreator.createValid(name("deleted"));
+    String secondaryEmail = "secondary@email.com";
+    gApi.accounts().id(deleted.id().get()).addEmail(newEmailInput(secondaryEmail));
+
+    requestScopeOperations.setApiUser(deleted.id());
+    gApi.accounts().self().delete();
+
+    requestScopeOperations.setApiUser(admin.id());
+    assertThrows(
+        ResourceNotFoundException.class, () -> gApi.accounts().id(deleted.id().get()).get());
+    assertThrows(NoSuchElementException.class, () -> accountCache.get(deleted.id()).get());
+
+    // Verifies the account is not queryable
+    assertThat(gApi.accounts().query(deleted.id().toString()).get()).isEmpty();
+    assertThat(gApi.accounts().query(deleted.username()).get()).isEmpty();
+    assertThat(gApi.accounts().query(deleted.fullName()).get()).isEmpty();
+    assertThat(gApi.accounts().query(deleted.displayName()).get()).isEmpty();
+    assertThat(gApi.accounts().query(deleted.email()).get()).isEmpty();
+    assertThat(gApi.accounts().query(secondaryEmail).get()).isEmpty();
+
+    // Clean up the test framework
+    accountCreator.evict(deleted.id());
+  }
+
+  @Test
+  public void deleteAccount_deletesAccountExternalIds() throws Exception {
+    TestAccount deleted =
+        accountCreator.create("deleted", "deleted@internal.com", "Full Name", "Display");
+    requestScopeOperations.setApiUser(deleted.id());
+    addExternalIdEmail(deleted, "deleted@external.com");
+    assertExternalEmails(
+        deleted.id(), ImmutableSet.of("deleted@internal.com", "deleted@external.com"));
+
+    gApi.accounts().self().delete();
+
+    requestScopeOperations.setApiUser(admin.id());
+    assertThat(externalIds.byEmails("deleted@internal.com", "deleted@external.com")).isEmpty();
+
+    // Clean up the test framework
+    accountCreator.evict(deleted.id());
+  }
+
+  @Test
+  @UseSsh
+  public void deleteAccount_deletesSshKeys() throws Exception {
+    TestAccount deleted = accountCreator.createValid(name("deleted"));
+    requestScopeOperations.setApiUser(deleted.id());
+    String newKey = TestSshKeys.publicKey(SshSessionFactory.genSshKey(), deleted.email());
+    gApi.accounts().self().addSshKey(newKey);
+    assertThat(gApi.accounts().self().listSshKeys()).hasSize(1);
+    assertThat(authorizedKeys.getKeys(deleted.id())).hasSize(1);
+
+    gApi.accounts().self().delete();
+
+    requestScopeOperations.setApiUser(admin.id());
+    assertThat(authorizedKeys.getKeys(deleted.id())).isEmpty();
+
+    // Clean up the test framework
+    accountCreator.evict(deleted.id());
+  }
+
+  @Test
+  public void deleteAccount_deletesGpgKeys() throws Exception {
+    TestAccount deleted = accountCreator.createValid(name("deleted"));
+
+    requestScopeOperations.setApiUser(deleted.id());
+    addExternalIdEmail(
+        deleted,
+        PushCertificateIdent.parse(validKeyWithoutExpiration().getFirstUserId()).getEmailAddress());
+    TestKey key = validKeyWithoutExpiration();
+    addGpgKey(deleted, key.getPublicKeyArmored());
+    assertKeys(key);
+    assertIteratorSize(1, getOnlyKeyFromStore(key).getUserIDs());
+
+    gApi.accounts().self().delete();
+
+    requestScopeOperations.setApiUser(admin.id());
+    try (PublicKeyStore store = publicKeyStoreProvider.get()) {
+      Iterable<PGPPublicKeyRing> keys = store.get(key.getKeyId());
+      assertThat(keys).isEmpty();
+    }
+
+    // Clean up the test framework
+    accountCreator.evict(deleted.id());
+  }
+
+  @Test
+  public void deleteAccount_deletesStarredChanges() throws Exception {
+    TestAccount deleted = accountCreator.createValid(name("deleted"));
+    PushOneCommit.Result r = createChange();
+    String triplet = project.get() + "~master~" + r.getChangeId();
+
+    requestScopeOperations.setApiUser(deleted.id());
+
+    gApi.accounts().self().starChange(triplet);
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(
+              repo.getRefDatabase()
+                  .getRefsByPrefix(RefNames.refsStarredChangesPrefix(r.getChange().getId())))
+          .hasSize(1);
+
+      gApi.accounts().self().delete();
+    }
+
+    // Reopen the repo to refresh RefDb
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(
+              repo.getRefDatabase()
+                  .getRefsByPrefix(RefNames.refsStarredChangesPrefix(r.getChange().getId())))
+          .isEmpty();
+    }
+
+    // Clean up the test framework
+    accountCreator.evict(deleted.id());
+  }
+
+  @Test
+  public void deleteAccount_deletesChangeEdits() throws Exception {
+    TestAccount deleted = accountCreator.createValid(name("deleted"));
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(deleted.id());
+
+    gApi.changes().id(r.getChangeId()).edit().create();
+    gApi.changes()
+        .id(r.getChangeId())
+        .edit()
+        .modifyFile(PushOneCommit.FILE_NAME, RawInputUtil.create("foo".getBytes(UTF_8)));
+    try (Repository repo = repoManager.openRepository(r.getChange().change().getProject())) {
+      assertThat(repo.getRefDatabase().getRefsByPrefix(RefNames.refsEditPrefix(deleted.id())))
+          .hasSize(1);
+
+      gApi.accounts().self().delete();
+    }
+
+    // Reopen the repo to refresh RefDb
+    try (Repository repo = repoManager.openRepository(r.getChange().change().getProject())) {
+      assertThat(repo.getRefDatabase().getRefsByPrefix(RefNames.refsEditPrefix(deleted.id())))
+          .isEmpty();
+    }
+
+    // Clean up the test framework
+    accountCreator.evict(deleted.id());
+  }
+
+  @Test
+  public void deleteAccount_deletesDraftComments() throws Exception {
+    TestAccount deleted = accountCreator.createValid(name("deleted"));
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(deleted.id());
+
+    createDraft(r, PushOneCommit.FILE_NAME, "draft");
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(
+              repo.getRefDatabase()
+                  .getRefsByPrefix(RefNames.refsDraftCommentsPrefix(r.getChange().getId())))
+          .hasSize(1);
+
+      gApi.accounts().self().delete();
+    }
+
+    // Reopen the repo to refresh RefDb
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      assertThat(
+              repo.getRefDatabase()
+                  .getRefsByPrefix(RefNames.refsDraftCommentsPrefix(r.getChange().getId())))
+          .isEmpty();
+    }
+
+    // Clean up the test framework
+    accountCreator.evict(deleted.id());
+  }
+
+  @Test
+  @SuppressWarnings("unused")
+  public void deleteAccount_deletesReviewedFlags() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestAccount deleted = accountCreator.createValid(name("deleted"));
+    ReviewerInput in = new ReviewerInput();
+    in.reviewer = deleted.email();
+    gApi.changes().id(r.getChangeId()).addReviewer(in);
+
+    requestScopeOperations.setApiUser(deleted.id());
+
+    var unused =
+        accountPatchReviewStore.markReviewed(
+            r.getPatchSetId(), deleted.id(), PushOneCommit.FILE_NAME);
+    assertThat(accountPatchReviewStore.findReviewed(r.getPatchSetId(), deleted.id())).isPresent();
+
+    gApi.accounts().self().delete();
+
+    assertThat(accountPatchReviewStore.findReviewed(r.getPatchSetId(), deleted.id())).isEmpty();
+
+    // Clean up the test framework
+    accountCreator.evict(deleted.id());
+  }
+
+  @Test
+  public void deleteAccount_appliesForSelfById() throws Exception {
+    TestAccount deleted = accountCreator.createValid(name("deleted"));
+    requestScopeOperations.setApiUser(deleted.id());
+    gApi.accounts().id(deleted.id().get()).delete();
+
+    // Clean up the test framework
+    accountCreator.evict(deleted.id());
+  }
+
+  @Test
+  public void deleteAccount_throwsForOtherUsers() throws Exception {
+    TestAccount deleted = accountCreator.createValid(name("deleted"));
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(AuthException.class, () -> gApi.accounts().id(deleted.id().get()).delete());
+    assertThat(thrown).hasMessageThat().isEqualTo("Delete account is only permitted for self");
+  }
+
   private TestGroupBackend createTestGroupBackendWithAllUsersGroup(String nameOfAllUsersGroup)
       throws IOException {
     TestGroupBackend testGroupBackend = new TestGroupBackend();
@@ -3272,7 +3441,7 @@
     return testGroupBackend;
   }
 
-  private void assertExternalIds(Account.Id accountId, ImmutableSet<String> extIds)
+  protected void assertExternalIds(Account.Id accountId, ImmutableSet<String> extIds)
       throws Exception {
     assertThat(
             gApi.accounts().id(accountId.get()).getExternalIds().stream()
@@ -3281,6 +3450,16 @@
         .isEqualTo(extIds);
   }
 
+  private void assertExternalEmails(Account.Id accountId, ImmutableSet<String> extIds)
+      throws Exception {
+    assertThat(
+            gApi.accounts().id(accountId.get()).getExternalIds().stream()
+                .map(e -> e.emailAddress)
+                .filter(Objects::nonNull)
+                .collect(toImmutableSet()))
+        .isEqualTo(extIds);
+  }
+
   private static Correspondence<GroupInfo, String> getGroupToNameCorrespondence() {
     return NullAwareCorrespondence.transforming(groupInfo -> groupInfo.name, "has name");
   }
@@ -3358,7 +3537,9 @@
 
     // Check raw stored keys.
     for (TestKey key : expected) {
-      getOnlyKeyFromStore(key);
+      try (PublicKeyStore store = publicKeyStoreProvider.get()) {
+        assertThat(store.get(key.getKeyId())).hasSize(1);
+      }
     }
   }
 
@@ -3395,10 +3576,12 @@
     }
   }
 
+  @CanIgnoreReturnValue
   private Map<String, GpgKeyInfo> addGpgKey(String armored) throws Exception {
     return addGpgKey(admin, armored);
   }
 
+  @CanIgnoreReturnValue
   private Map<String, GpgKeyInfo> addGpgKey(TestAccount account, String armored) throws Exception {
     return testRefAction(
         () -> {
@@ -3431,8 +3614,15 @@
     assertThat(info.status).isEqualTo(expectedStatus);
   }
 
-  private Set<String> getEmails() throws RestApiException {
-    return gApi.accounts().self().getEmails().stream().map(e -> e.email).collect(toSet());
+  private ImmutableSet<String> getEmails() throws RestApiException {
+    return gApi.accounts().self().getEmails().stream().map(e -> e.email).collect(toImmutableSet());
+  }
+
+  private ImmutableSet<String> getExtIdsEmail() throws RestApiException {
+    return gApi.accounts().self().getExternalIds().stream()
+        .map(e -> e.emailAddress)
+        .filter(Objects::nonNull)
+        .collect(toImmutableSet());
   }
 
   private void assertEmail(Set<Account.Id> accounts, TestAccount expectedAccount) {
@@ -3457,6 +3647,36 @@
         "login?account_id=" + accountId, HttpServletResponse.SC_MOVED_TEMPORARILY);
   }
 
+  private AccountsUpdate getAccountsUpdate(Runnable afterReadRevision, Runnable beforeCommit) {
+    return getAccountsUpdate(
+        afterReadRevision,
+        beforeCommit,
+        new RetryHelper(
+            cfg,
+            retryMetrics,
+            null,
+            null,
+            null,
+            exceptionHooks,
+            r -> r.withBlockStrategy(noSleepBlockStrategy)));
+  }
+
+  private AccountsUpdate getAccountsUpdate(
+      Runnable afterReadRevision, Runnable beforeCommit, RetryHelper retryHelper) {
+    return new AccountsUpdateNoteDbImpl(
+        repoManager,
+        gitReferenceUpdated,
+        Optional.empty(),
+        allUsers,
+        externalIds,
+        extIdNotesFactory,
+        metaDataUpdateInternalFactory,
+        retryHelper,
+        serverIdent.get(),
+        afterReadRevision,
+        beforeCommit);
+  }
+
   private void httpGetAndAssertStatus(String urlPath, int expectedHttpStatus)
       throws ClientProtocolException, IOException {
     HttpGet httpGet = new HttpGet(canonicalWebUrl.get() + urlPath);
@@ -3464,10 +3684,12 @@
     assertThat(loginResponse.getStatusLine().getStatusCode()).isEqualTo(expectedHttpStatus);
   }
 
-  private static class RefUpdateCounter implements GitReferenceUpdatedListener {
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public static class RefUpdateCounter implements GitReferenceUpdatedListener {
     private final AtomicLongMap<String> countsByProjectRefs = AtomicLongMap.create();
 
-    static String projectRef(Project.NameKey project, String ref) {
+    @UsedAt(UsedAt.Project.GOOGLE)
+    public static String projectRef(Project.NameKey project, String ref) {
       return projectRef(project.get(), ref);
     }
 
@@ -3484,7 +3706,8 @@
       countsByProjectRefs.clear();
     }
 
-    void assertRefUpdateFor(String... projectRefs) {
+    @UsedAt(UsedAt.Project.GOOGLE)
+    public void assertRefUpdateFor(String... projectRefs) {
       Map<String, Long> expectedRefUpdateCounts = new HashMap<>();
       for (String projectRef : projectRefs) {
         expectedRefUpdateCounts.put(projectRef, 1L);
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
index 0d246e3..39fa918 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
@@ -18,18 +18,21 @@
 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.RefNames.REFS_EXTERNAL_IDS;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GOOGLE_OAUTH;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
@@ -41,17 +44,20 @@
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
-import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNotes;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gerrit.server.update.context.RefUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.util.Providers;
+import java.io.IOException;
 import java.util.Optional;
 import java.util.Set;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
 
@@ -781,6 +787,39 @@
     assertThat(result.getAccountId().get()).isEqualTo(accountId.get());
   }
 
+  @Test
+  public void updateLinkInSingleCommit() throws Exception {
+    String username = "baz";
+    String email1 = "baz@foo.com";
+    String email2 = "baz@bar.com";
+
+    ExternalId.Key mailExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_MAILTO, username);
+    Account.Id accountId = Account.id(seq.nextAccountId());
+    accountsUpdate.insert(
+        "Create Test Account",
+        accountId,
+        u -> u.addExternalId(externalIdFactory.create(mailExtIdKey, accountId)));
+
+    accountManager.link(accountId, authRequestFactory.createForEmail(email1));
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        Git git = new Git(allUsersRepo)) {
+      int initialCommits = getCommitsInExternalIds(git, allUsersRepo);
+
+      accountManager.updateLink(accountId, authRequestFactory.createForEmail(email2));
+
+      int afterUpdateCommits = getCommitsInExternalIds(git, allUsersRepo);
+
+      assertThat(afterUpdateCommits).isEqualTo(initialCommits + 1);
+    }
+  }
+
+  private static int getCommitsInExternalIds(Git git, Repository allUsersRepo)
+      throws GitAPIException, IOException {
+    ObjectId refsMetaExternalIdsHead = allUsersRepo.exactRef(REFS_EXTERNAL_IDS).getObjectId();
+    return Iterables.size(git.log().add(refsMetaExternalIdsHead).call());
+  }
+
   private void assertNoSuchExternalIds(ExternalId.Key... extIdKeys) throws Exception {
     for (ExternalId.Key extIdKey : extIdKeys) {
       assertWithMessage(extIdKey.get())
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index 59ba00b..eed9de8 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -43,7 +43,7 @@
 public class GeneralPreferencesIT extends AbstractDaemonTest {
   @Inject private ExtensionRegistry extensionRegistry;
 
-  private TestAccount user42;
+  protected TestAccount user42;
 
   @Before
   public void setUp() throws Exception {
@@ -84,6 +84,7 @@
     i.muteCommonPathPrefixes ^= true;
     i.signedOffBy ^= true;
     i.allowBrowserNotifications ^= false;
+    i.diffPageSidebar = "plugin-insight";
     i.diffView = DiffView.UNIFIED_DIFF;
     i.my = new ArrayList<>();
     i.my.add(new MenuItem("name", "url"));
@@ -96,6 +97,7 @@
     assertThat(o.changeTable).containsExactlyElementsIn(i.changeTable);
     assertThat(o.theme).isEqualTo(i.theme);
     assertThat(o.allowBrowserNotifications).isEqualTo(i.allowBrowserNotifications);
+    assertThat(o.diffPageSidebar).isEqualTo(i.diffPageSidebar);
     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
index f31ae9b..ce02a88 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
@@ -21,20 +21,26 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_FILES;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.patch.DiffUtil.cleanPatch;
+import static com.google.gerrit.server.patch.DiffUtil.normalizePatchForComparison;
 import static com.google.gerrit.server.patch.DiffUtil.removePatchHeader;
 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.hash.Hashing;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
+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.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
 import com.google.gerrit.extensions.api.changes.ApplyPatchPatchSetInput;
@@ -46,10 +52,14 @@
 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.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.RestApiException;
 import com.google.inject.Inject;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.util.Base64;
 import org.junit.Test;
@@ -59,7 +69,7 @@
   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_CONTENT = "First added line\nSecond added line";
   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"
@@ -67,10 +77,13 @@
           + "+++ b/a_new_file.txt\n"
           + "@@ -0,0 +1,2 @@\n"
           + "+First added line\n"
-          + "+Second added line\n";
+          + "+Second added line\n"
+          + "\\ No newline at end of file\n";
 
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ChangeOperations changeOperations;
+  @Inject private AccountOperations accountOperations;
 
   @Test
   public void applyAddedFilePatch_success() throws Exception {
@@ -83,6 +96,27 @@
     assertDiffForNewFile(diff, result.currentRevision, ADDED_FILE_NAME, ADDED_FILE_CONTENT);
   }
 
+  @Test
+  public void applyAddedFilePatchAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Create change
+    Change.Id change = changeOperations.newChange().project(project).owner(testUser).create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Apply patch
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+    in.responseFormatOptions = ImmutableList.of(CURRENT_REVISION, CURRENT_COMMIT);
+    ChangeInfo result = gApi.changes().id(change.get()).applyPatch(in);
+
+    assertThat(result.getCurrentRevision().commit.committer.email).isEqualTo(emailOne);
+  }
+
   private static final String MODIFIED_FILE_NAME = "modified_file.txt";
   private static final String MODIFIED_FILE_ORIGINAL_CONTENT =
       "First original line\nSecond original line";
@@ -97,6 +131,13 @@
           + "+Modified line\n";
 
   @Test
+  public void applyPatchWithoutProvidingPatch_badRequest() throws Exception {
+    initBaseWithFile(MODIFIED_FILE_NAME, MODIFIED_FILE_ORIGINAL_CONTENT);
+    Throwable error = assertThrows(BadRequestException.class, () -> applyPatch(buildInput(null)));
+    assertThat(error).hasMessageThat().isEqualTo("patch required");
+  }
+
+  @Test
   public void applyModifiedFilePatch_success() throws Exception {
     initBaseWithFile(MODIFIED_FILE_NAME, MODIFIED_FILE_ORIGINAL_CONTENT);
     ApplyPatchPatchSetInput in = buildInput(MODIFIED_FILE_DIFF);
@@ -157,6 +198,28 @@
   }
 
   @Test
+  public void applyValidTraditionalPatch_success() throws Exception {
+    final String fileName = "file_name.txt";
+    final String originalContent = "original line";
+    final String newContent = "new line\n";
+    final String diff =
+        "diff file_name.txt file_name.txt\n"
+            + "--- file_name.txt\n"
+            + "+++ file_name.txt\n"
+            + "@@ -1 +1 @@\n"
+            + "-original line\n"
+            + "+new line\n";
+    initBaseWithFile(fileName, originalContent);
+    ApplyPatchPatchSetInput in = buildInput(diff);
+
+    ChangeInfo result = applyPatch(in);
+
+    DiffInfo fileDiff = fetchDiffForFile(result, fileName);
+    assertDiffForFullyModifiedFile(
+        fileDiff, result.currentRevision, fileName, originalContent, newContent);
+  }
+
+  @Test
   public void applyGerritBasedPatchWithSingleFile_success() throws Exception {
     String head = getHead(repo(), HEAD).name();
     createBranchWithRevision(BranchNameKey.create(project, "branch"), head);
@@ -169,7 +232,8 @@
     ChangeInfo result = applyPatch(in);
 
     BinaryResult resultPatch = gApi.changes().id(result.id).current().patch();
-    assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(originalPatch));
+    assertThat(normalizePatchForComparison(resultPatch))
+        .isEqualTo(normalizePatchForComparison(originalPatch));
   }
 
   @Test
@@ -191,7 +255,8 @@
     ChangeInfo result = applyPatch(in);
 
     BinaryResult resultPatch = gApi.changes().id(result.id).current().patch();
-    assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(originalPatch));
+    assertThat(normalizePatchForComparison(resultPatch))
+        .isEqualTo(normalizePatchForComparison(originalPatch));
   }
 
   @Test
@@ -214,7 +279,8 @@
 
     resp.assertOK();
     BinaryResult resultPatch = gApi.changes().id(destChange.getChangeId()).current().patch();
-    assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(originalPatch));
+    assertThat(normalizePatchForComparison(resultPatch))
+        .isEqualTo(normalizePatchForComparison(originalPatch));
   }
 
   @Test
@@ -238,7 +304,8 @@
 
     resp.assertOK();
     BinaryResult resultPatch = gApi.changes().id(destChange.getChangeId()).current().patch();
-    assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(originalDecodedPatch));
+    assertThat(normalizePatchForComparison(resultPatch))
+        .isEqualTo(normalizePatchForComparison(originalDecodedPatch));
   }
 
   @Test
@@ -267,6 +334,46 @@
   }
 
   @Test
+  public void applyPatchWithConflict_appendErrorsToCommitMessageWithLargeOriginalPatch()
+      throws Exception {
+    initBaseWithFile(MODIFIED_FILE_NAME, "Unexpected base content");
+    String modifiedFileDiff =
+        "diff --git a/modified_file.txt b/modified_file.txt\n"
+            + "--- a/modified_file.txt\n"
+            + "+++ b/modified_file.txt\n"
+            + "@@ -1,2 +1,1001 @@\n"
+            + "-First original line\n"
+            + "-Second original line\n"
+            + "+Modified line\n"
+            + "+1000 additional lines\n".repeat(1000);
+    String patch = ADDED_FILE_DIFF + modifiedFileDiff;
+    ApplyPatchPatchSetInput in = buildInput(patch);
+    in.commitMessage = "subject";
+
+    ChangeInfo result = applyPatch(in);
+
+    assertThat(gApi.changes().id(result.id).current().commit(false).message)
+        .isEqualTo(
+            in.commitMessage
+                + "\n\nNOTE FOR REVIEWERS - errors occurred while applying the patch."
+                + "\nPLEASE REVIEW CAREFULLY.\nErrors:\nError applying patch in "
+                + MODIFIED_FILE_NAME
+                + ", hunk HunkHeader[1,2->1,1001]: Hunk cannot be applied\n\nOriginal patch:\n "
+                + removePatchHeader(patch).substring(0, 1024)
+                + "\n[[[Original patch trimmed due to size. Decoded string size: "
+                + removePatchHeader(patch).length()
+                + ". Decoded string SHA1: "
+                + Hashing.sha1().hashString(removePatchHeader(patch), UTF_8)
+                + ".]]]"
+                + "\n\nChange-Id: "
+                + result.changeId
+                + "\n");
+    // Error in MODIFIED_FILE should not affect ADDED_FILE results.
+    DiffInfo diff = fetchDiffForFile(result, ADDED_FILE_NAME);
+    assertDiffForNewFile(diff, result.currentRevision, ADDED_FILE_NAME, ADDED_FILE_CONTENT);
+  }
+
+  @Test
   public void applyPatchWithoutAddPatchSetPermissions_fails() throws Exception {
     initDestBranch();
     ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
@@ -428,7 +535,8 @@
         .isEqualTo(inputParent.getCommit().name());
 
     BinaryResult resultPatch = gApi.changes().id(dest.getChangeId()).current().patch();
-    assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(ADDED_FILE_DIFF));
+    assertThat(normalizePatchForComparison(resultPatch))
+        .isEqualTo(normalizePatchForComparison(ADDED_FILE_DIFF));
   }
 
   @Test
@@ -466,6 +574,38 @@
   }
 
   @Test
+  public void commitMessage_providedMessageWithCorrectChangeId() throws Exception {
+    initDestBranch();
+    String originalChangeId =
+        gApi.changes()
+            .create(new ChangeInput(project.get(), DESTINATION_BRANCH, "Default commit message"))
+            .info()
+            .changeId;
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+    in.commitMessage = "custom commit message\n\nChange-Id: " + originalChangeId + "\n";
+
+    ChangeInfo result = gApi.changes().id(originalChangeId).applyPatch(in);
+
+    ChangeInfo info = get(result.changeId, CURRENT_REVISION, CURRENT_COMMIT);
+    assertThat(info.revisions.get(info.currentRevision).commit.message).isEqualTo(in.commitMessage);
+  }
+
+  @Test
+  public void commitMessage_providedMessageWithWrongChangeId() throws Exception {
+    initDestBranch();
+    String originalChangeId =
+        gApi.changes()
+            .create(new ChangeInput(project.get(), DESTINATION_BRANCH, "Default commit message"))
+            .info()
+            .changeId;
+    ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+    in.commitMessage = "custom commit message\n\nChange-Id: " + "I1234567890" + "\n";
+
+    assertThrows(
+        ResourceConflictException.class, () -> gApi.changes().id(originalChangeId).applyPatch(in));
+  }
+
+  @Test
   public void commitMessage_defaultMessageAndPatchHeader() throws Exception {
     initDestBranch();
     ApplyPatchPatchSetInput in = buildInput("Patch header\n" + ADDED_FILE_DIFF);
@@ -489,6 +629,155 @@
         .isEqualTo("Default commit message\n\nChange-Id: " + result.changeId + "\n");
   }
 
+  @Test
+  public void amendCommitWithValidTraditionalPatch_success() throws Exception {
+    final String fileName = "file_name.txt";
+    final String originalContent = "original line";
+    final String newContent = "new line\n";
+    final String diff =
+        "diff file_name.txt file_name.txt\n"
+            + "--- file_name.txt\n"
+            + "+++ file_name.txt\n"
+            + "@@ -1 +1 @@\n"
+            + "-original line\n"
+            + "+new line\n";
+
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo, "Test", fileName, "foo");
+    PushOneCommit.Result base = push.to("refs/heads/foo");
+    base.assertOkStatus();
+
+    PushOneCommit.Result firstPatchSet =
+        createChange(
+            testRepo, "foo", "Add original file: " + fileName, fileName, originalContent, null);
+    firstPatchSet.assertOkStatus();
+
+    ApplyPatchPatchSetInput in = new ApplyPatchPatchSetInput();
+    in.patch = new ApplyPatchInput();
+    in.patch.patch = diff;
+    in.amend = true;
+    in.responseFormatOptions =
+        ImmutableList.of(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT);
+
+    ChangeInfo result = gApi.changes().id(firstPatchSet.getChangeId()).applyPatch(in);
+
+    // Parent of patch set 2 = parent of patch set 1, so we actually amended
+    assertThat(result.revisions.get(result.currentRevision).commit.parents.get(0).commit)
+        .isEqualTo(base.getCommit().getId().getName());
+    DiffInfo fileDiff = gApi.changes().id(result.id).current().file(fileName).diff();
+    assertDiffForFullyModifiedFile(fileDiff, result.currentRevision, fileName, "foo", newContent);
+    assertThat(gApi.changes().id(firstPatchSet.getChangeId()).current().commit(false).message)
+        .isEqualTo(firstPatchSet.getCommit().getFullMessage());
+  }
+
+  @Test
+  public void amendCantBeUsedWithBase() throws Exception {
+    final String diff =
+        "diff file_name.txt file_name.txt\n"
+            + "--- file_name.txt\n"
+            + "+++ file_name.txt\n"
+            + "@@ -1 +1 @@\n"
+            + "-original line\n"
+            + "+new line\n";
+    PushOneCommit.Result change = createChange();
+    ApplyPatchPatchSetInput in = new ApplyPatchPatchSetInput();
+    in.patch = new ApplyPatchInput();
+    in.patch.patch = diff;
+    in.amend = true;
+    in.base = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(change.getChangeId()).applyPatch(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("amend only works with existing revisions. omit base.");
+  }
+
+  @Test
+  public void amendCommitWithConflict_appendErrorsToCommitMessage() throws Exception {
+    final String fileName = "file_name.txt";
+    final String originalContent = "original line";
+    final String diff =
+        "diff file_name.txt file_name.txt\n"
+            + "--- file_name.txt\n"
+            + "+++ file_name.txt\n"
+            + "@@ -1 +1 @@\n"
+            + "-xxx line\n"
+            + "+new line\n";
+
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo, "Test", fileName, "foo");
+    PushOneCommit.Result base = push.to("refs/heads/foo");
+    base.assertOkStatus();
+
+    PushOneCommit.Result firstPatchSet =
+        createChange(
+            testRepo, "foo", "Add original file: " + fileName, fileName, originalContent, null);
+    firstPatchSet.assertOkStatus();
+
+    ApplyPatchPatchSetInput in = new ApplyPatchPatchSetInput();
+    in.patch = new ApplyPatchInput();
+    in.patch.patch = diff;
+    in.amend = true;
+    in.responseFormatOptions =
+        ImmutableList.of(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT);
+
+    ChangeInfo result = gApi.changes().id(firstPatchSet.getChangeId()).applyPatch(in);
+    assertThat(gApi.changes().id(result.id).current().commit(false).message)
+        .startsWith(
+            "Add original file: file_name.txt\n"
+                + "\n"
+                + "NOTE FOR REVIEWERS - errors occurred while applying the patch.\n"
+                + "PLEASE REVIEW CAREFULLY.\n"
+                + "Errors:\n"
+                + "Error applying patch in file_name.txt, hunk HunkHeader[1,1->1,1]: Hunk cannot be"
+                + " applied\n"
+                + "\n"
+                + "Original patch:\n"
+                + " diff file_name.txt file_name.txt\n"
+                + "--- file_name.txt\n"
+                + "+++ file_name.txt\n"
+                + "@@ -1 +1 @@\n"
+                + "-xxx line\n"
+                + "+new line");
+  }
+
+  @Test
+  public void amendCommitWithValidTraditionalPatchEmptyRepo_resourceNotFound() throws Exception {
+    final String fileName = "file_name.txt";
+    final String originalContent = "original line";
+    final String diff =
+        "diff file_name.txt file_name.txt\n"
+            + "--- file_name.txt\n"
+            + "+++ file_name.txt\n"
+            + "@@ -1 +1 @@\n"
+            + "-original line\n"
+            + "+new line\n";
+
+    Project.NameKey emptyProject = projectOperations.newProject().noEmptyCommit().create();
+    TestRepository<InMemoryRepository> emptyClone = cloneProject(emptyProject);
+    PushOneCommit.Result firstPatchSet =
+        createChange(
+            emptyClone,
+            "master",
+            "Add original file: " + fileName,
+            fileName,
+            originalContent,
+            null);
+    firstPatchSet.assertOkStatus();
+
+    ApplyPatchPatchSetInput in = new ApplyPatchPatchSetInput();
+    in.patch = new ApplyPatchInput();
+    in.patch.patch = diff;
+    in.amend = true;
+
+    Throwable error =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () -> gApi.changes().id(firstPatchSet.getChangeId()).applyPatch(in));
+    assertThat(error).hasMessageThat().contains("Branch refs/heads/master does not exist");
+  }
+
   private void initDestBranch() throws Exception {
     String head = getHead(repo(), HEAD).name();
     createBranchWithRevision(BranchNameKey.create(project, ApplyPatchIT.DESTINATION_BRANCH), head);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 8e2bd8b..cab92aa 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -49,7 +49,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.server.StarredChangesUtil.DEFAULT_LABEL;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
 import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
@@ -90,6 +89,7 @@
 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.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -238,6 +238,7 @@
   @Inject private ExtensionRegistry extensionRegistry;
   @Inject private IndexOperations.Change changeIndexOperations;
   @Inject private AccountControl.Factory accountControlFactory;
+  @Inject private ChangeOperations changeOperations;
 
   @Inject
   @Named("diff_intraline")
@@ -248,11 +249,14 @@
   private Cache<DiffSummaryKey, DiffSummary> diffSummaryCache;
 
   @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = "GerritBackendFeature__return_new_change_info_id")
   public void get() throws Exception {
     PushOneCommit.Result r = createChange();
-    String triplet = project.get() + "~master~" + r.getChangeId();
-    ChangeInfo c = info(triplet);
-    assertThat(c.id).isEqualTo(triplet);
+    String id = project.get() + "~" + r.getChange().getId().get();
+    ChangeInfo c = info(id);
+    assertThat(c.id).isEqualTo(id);
     assertThat(c.project).isEqualTo(project.get());
     assertThat(c.branch).isEqualTo("master");
     assertThat(c.status).isEqualTo(ChangeStatus.NEW);
@@ -439,7 +443,7 @@
     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);
+    String expectedMessage = String.format("Created a revert of this change as %s", changeId);
     assertThat(sourceMessages.get(3).message).isEqualTo(expectedMessage);
   }
 
@@ -505,6 +509,7 @@
             .reviewer("byemail3@example.com", CC, false)
             .reviewer("byemail4@example.com", CC, false);
     ReviewResult result = gApi.changes().id(changeId).current().review(in);
+    assertThat(result.changeInfo).isNull();
     assertThat(result.reviewers).isNotEmpty();
     ChangeInfo info = gApi.changes().id(changeId).get();
     Function<Collection<AccountInfo>, Collection<String>> toEmails =
@@ -684,6 +689,53 @@
   }
 
   @Test
+  public void removeReviewerWithoutPermissionsOnChangePostReview_allowed() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ReviewInput in = ReviewInput.approve().reviewer(user.email());
+    gApi.changes().id(r.getChangeId()).current().review(in);
+    AccountGroup.UUID restrictedGroup =
+        groupOperations.newGroup().name("restricted-group").addMember(user.id()).create();
+
+    // revoke permissions to see the change from the reviewer
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(restrictedGroup))
+        .update();
+
+    in = ReviewInput.noScore().reviewer(Integer.toString(user.id().get()), REMOVED, false);
+
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(r.getChangeId()).current().review(in);
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    assertThat(info.reviewers.get(REVIEWER).stream().map(ai -> ai._accountId).collect(toList()))
+        .containsExactly(admin.id().get());
+  }
+
+  @Test
+  public void removeReviewerWithoutPermissionsOnChange_allowed() throws Exception {
+
+    PushOneCommit.Result r = createChange();
+    ReviewInput in = ReviewInput.approve().reviewer(user.email());
+    gApi.changes().id(r.getChangeId()).current().review(in);
+    AccountGroup.UUID restrictedGroup =
+        groupOperations.newGroup().name("restricted-group").addMember(user.id()).create();
+
+    // revoke permissions to see the change from the reviewer
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(restrictedGroup))
+        .update();
+
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes().id(r.getChangeId()).reviewer(user.id().toString()).remove();
+    ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+    assertThat(info.reviewers.get(REVIEWER).stream().map(ai -> ai._accountId).collect(toList()))
+        .containsExactly(admin.id().get());
+  }
+
+  @Test
   public void reviewWithWorkInProgressAndReadyReturnsError() throws Exception {
     PushOneCommit.Result r = createChange();
     ReviewInput in = ReviewInput.noScore();
@@ -2642,11 +2694,27 @@
 
   @Test
   public void queryChangesLimit() throws Exception {
-    createChange();
-    PushOneCommit.Result r2 = createChange();
-    List<ChangeInfo> results = gApi.changes().query().withLimit(1).get();
-    assertThat(results).hasSize(1);
-    assertThat(Iterables.getOnlyElement(results).changeId).isEqualTo(r2.getChangeId());
+    for (int i = 0; i < 3; i++) {
+      createChange();
+    }
+    List<ChangeInfo> resultsLimited = gApi.changes().query().withLimit(1).get();
+    List<ChangeInfo> resultsUnlimited = gApi.changes().query().get();
+    assertThat(resultsLimited).hasSize(1);
+    assertThat(resultsUnlimited.size()).isAtLeast(3);
+  }
+
+  @Test
+  @GerritConfig(name = "index.defaultLimit", value = "2")
+  public void queryChangesLimitDefault() throws Exception {
+    for (int i = 0; i < 4; i++) {
+      createChange();
+    }
+    List<ChangeInfo> resultsLimited = gApi.changes().query().withLimit(1).get();
+    List<ChangeInfo> resultsUnlimited = gApi.changes().query().get();
+    List<ChangeInfo> resultsLimitedAboveDefault = gApi.changes().query().withLimit(3).get();
+    assertThat(resultsLimited).hasSize(1);
+    assertThat(resultsUnlimited).hasSize(2);
+    assertThat(resultsLimitedAboveDefault).hasSize(3);
   }
 
   @Test
@@ -2845,6 +2913,23 @@
   }
 
   @Test
+  @GerritConfig(name = "change.topicLimit", value = "3")
+  public void topicSizeLimit() throws Exception {
+    for (int i = 0; i < 3; i++) {
+      createChangeWithTopic(testRepo, "limitedTopic", "message", "a.txt", "content\n");
+    }
+    PushOneCommit.Result rLimited =
+        pushFactory
+            .create(user.newIdent(), testRepo)
+            .to("refs/for/master%topic=" + name("limitedTopic"));
+    rLimited.assertErrorStatus("topicLimit");
+
+    PushOneCommit.Result rOther =
+        createChangeWithTopic(testRepo, "otherTopic", "message", "a.txt", "content\n");
+    assertThat(gApi.changes().id(rOther.getChangeId()).topic()).contains("otherTopic");
+  }
+
+  @Test
   public void editTopicWithoutPermissionNotAllowed() throws Exception {
     PushOneCommit.Result r = createChange();
     assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("");
@@ -3927,6 +4012,28 @@
   }
 
   @Test
+  public void changeCommitMessageAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Create change
+    Change.Id change = changeOperations.newChange().project(project).owner(testUser).create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Change commit message
+    ChangeInfo changeInfo = gApi.changes().id(change.get()).get();
+    String msg = String.format("New commit message\n\nChange-Id: %s\n", changeInfo.changeId);
+    gApi.changes().id(change.get()).setMessage(msg);
+
+    assertThat(gApi.changes().id(change.get()).get().getCurrentRevision().commit.committer.email)
+        .isEqualTo(emailOne);
+  }
+
+  @Test
   public void changeCommitMessageFromChangeIdToLinkFooter() throws Exception {
     PushOneCommit.Result r = createChange();
     r.assertOkStatus();
@@ -4367,14 +4474,12 @@
       gApi.accounts().self().starChange(triplet);
       ChangeInfo change = info(triplet);
       assertThat(change.starred).isTrue();
-      assertThat(change.stars).contains(DEFAULT_LABEL);
       // 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();
       // change was not re-indexed
       changeIndexedCounter.assertReindexOf(change, 0);
     }
@@ -4596,6 +4701,18 @@
     testEmailSubjectContainsChangeSizeBucket(1000, "XL");
   }
 
+  @Test
+  public void requestFormattedChangeInReview() throws Exception {
+    PushOneCommit.Result r = createChange();
+    assertThat(r.getChange().change().isWorkInProgress()).isFalse();
+
+    ReviewInput in = ReviewInput.approve().reviewer(user.email()).label(LabelId.CODE_REVIEW, 1);
+    in.responseFormatOptions = ImmutableList.of(ListChangesOption.CURRENT_REVISION);
+    ReviewResult result = gApi.changes().id(r.getChangeId()).current().review(in);
+    assertThat(result.changeInfo).isNotNull();
+    assertThat(result.changeInfo.currentRevision).isNotNull();
+  }
+
   private void testEmailSubjectContainsChangeSizeBucket(
       int numberOfLines, String expectedSizeBucket) throws Exception {
     String change;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
index 2bde1652..502f286 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
@@ -25,14 +25,21 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.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.project.TestProjectUpdate;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -46,6 +53,10 @@
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 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.List;
@@ -58,6 +69,8 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private ChangeOperations changeOperations;
+  @Inject private AccountOperations accountOperations;
 
   @Before
   public void setUp() {
@@ -150,6 +163,35 @@
   }
 
   @Test
+  public void createMergePatchSetAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+    String branch = "dev";
+    createBranch(BranchNameKey.create(project, branch));
+
+    // Create a change for master branch
+    Change.Id change = changeOperations.newChange().project(project).owner(testUser).create();
+
+    // Push a commit to dev branch
+    createChange("refs/heads/dev");
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Create merge patch-set
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = branch;
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    gApi.changes().id(change.get()).createMergePatchSet(in);
+
+    assertThat(gApi.changes().id(change.get()).get().getCurrentRevision().commit.committer.email)
+        .isEqualTo(emailOne);
+  }
+
+  @Test
   public void createMergePatchSet_Conflict() throws Exception {
     RevCommit initialHead = projectOperations.project(project).getHead("master");
     createBranch(BranchNameKey.create(project, "dev"));
@@ -361,6 +403,43 @@
   }
 
   @Test
+  public void createMergePatchSetWithValidationOption() throws Exception {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    createBranch(BranchNameKey.create(project, "dev"));
+
+    // create a change for master
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // advance master branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+    currentMaster.assertOkStatus();
+
+    // push a commit into dev branch
+    testRepo.reset(initialHead);
+    PushOneCommit.Result changeA =
+        pushFactory
+            .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+            .to("refs/heads/dev");
+    changeA.assertOkStatus();
+    MergeInput mergeInput = new MergeInput();
+    mergeInput.source = "dev";
+    MergePatchSetInput in = new MergePatchSetInput();
+    in.merge = mergeInput;
+    in.subject = "update change by merge ps2 inherit parent of ps1";
+    in.validationOptions = ImmutableMap.of("key", "value");
+
+    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+      gApi.changes().id(changeId).createMergePatchSet(in);
+      assertThat(testCommitValidationListener.receiveEvent.pushOptions)
+          .containsExactly("key", "value");
+    }
+  }
+
+  @Test
   public void createMergePatchSetCannotBaseOnInvisibleChange() throws Exception {
     RevCommit initialHead = projectOperations.project(project).getHead("master");
     createBranch(BranchNameKey.create(project, "foo"));
@@ -505,6 +584,7 @@
     assertThat(commitInfo.message).contains(subject);
     assertThat(commitInfo.author.name).isEqualTo("Other Author");
     assertThat(commitInfo.author.email).isEqualTo("otherauthor@example.com");
+    assertThat(commitInfo.committer.email).isEqualTo(admin.email());
   }
 
   @Test
@@ -652,4 +732,15 @@
           event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
     }
   }
+
+  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/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index a0f0fe6..1b06b7b 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 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;
@@ -58,6 +59,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -246,7 +248,10 @@
     input.drafts = DraftHandling.PUBLISH;
 
     gApi.changes().id(r.getChangeId()).current().review(input);
-    assertValidatorCalledWith(CHANGE_MESSAGE_FOR_VALIDATION, INLINE_COMMENT_FOR_VALIDATION);
+    // Comment validators called twice: first when the draft was created, and second when it was
+    // published.
+    assertValidatorCalledWith(
+        /* numInvocations= */ 2, CHANGE_MESSAGE_FOR_VALIDATION, INLINE_COMMENT_FOR_VALIDATION);
     assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).hasSize(1);
   }
 
@@ -259,16 +264,11 @@
     DraftInput draft =
         testCommentHelper.newDraft(
             r.getChange().currentFilePaths().get(0), Side.REVISION, 1, COMMENT_TEXT);
-    testCommentHelper.addDraft(r.getChangeId(), r.getCommit().getName(), draft);
-    assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).isEmpty();
-
-    ReviewInput input = new ReviewInput().message(COMMENT_TEXT);
-    input.drafts = DraftHandling.PUBLISH;
     BadRequestException badRequestException =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.changes().id(r.getChangeId()).current().review(input));
-    assertValidatorCalledWith(CHANGE_MESSAGE_FOR_VALIDATION, INLINE_COMMENT_FOR_VALIDATION);
+            () -> testCommentHelper.addDraft(r.getChangeId(), r.getCommit().getName(), draft));
+    assertValidatorCalledWith(INLINE_COMMENT_FOR_VALIDATION);
     assertThat(badRequestException.getCause()).isInstanceOf(CommentsRejectedException.class);
     assertThat(
             Iterables.getOnlyElement(
@@ -603,6 +603,50 @@
   }
 
   @Test
+  public void onPostReviewApprovedIsReturnedForLabelsAndDetailedLabels() throws Exception {
+    // Create Verify label with NO_BLOCK function and allow voting on it.
+    // When the NO_BLOCK function is used for a label, the "approved" is set by the
+    // LabelsJson.setLabelScores method instead of LabelsJson.initLabels method.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      LabelType.Builder verified =
+          labelBuilder(
+                  LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"))
+              .setFunction(LabelFunction.NO_BLOCK);
+      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);
+
+    input = new ReviewInput();
+    input.message = "first message";
+    input.responseFormatOptions = ImmutableList.of(ListChangesOption.DETAILED_LABELS);
+    ReviewResult reviewResult = gApi.changes().id(r.getChangeId()).current().review(input);
+    assertThat(reviewResult.changeInfo.labels.keySet())
+        .containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
+    assertThat(reviewResult.changeInfo.labels.get(LabelId.CODE_REVIEW).approved).isNotNull();
+    assertThat(reviewResult.changeInfo.labels.get(LabelId.VERIFIED).approved).isNotNull();
+
+    input = new ReviewInput();
+    input.message = "second message";
+    input.responseFormatOptions = ImmutableList.of(ListChangesOption.LABELS);
+    reviewResult = gApi.changes().id(r.getChangeId()).current().review(input);
+    assertThat(reviewResult.changeInfo.labels).containsKey(LabelId.CODE_REVIEW);
+    assertThat(reviewResult.changeInfo.labels.get(LabelId.CODE_REVIEW).approved).isNotNull();
+    assertThat(reviewResult.changeInfo.labels.get(LabelId.VERIFIED).approved).isNotNull();
+  }
+
+  @Test
   public void onPostReviewCallbackGetsCorrectApprovals() throws Exception {
     PushOneCommit.Result r = createChange();
 
@@ -1044,7 +1088,12 @@
   }
 
   private void assertValidatorCalledWith(CommentForValidation... commentsForValidation) {
-    assertThat(captor.getAllValues()).hasSize(1);
+    assertValidatorCalledWith(/* numInvocations= */ 1, commentsForValidation);
+  }
+
+  private void assertValidatorCalledWith(
+      int numInvocations, CommentForValidation... commentsForValidation) {
+    assertThat(captor.getAllValues()).hasSize(numInvocations);
     assertThat(captor.getValue())
         .comparingElementsUsing(COMMENT_CORRESPONDENCE)
         .containsExactly(commentsForValidation);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
index 3f3ad37..465a19a 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
@@ -343,7 +343,7 @@
   @Test
   public void testInvalidListChangeOption() throws Exception {
     PushOneCommit.Result r = createChange();
-    RestResponse rep = adminRestSession.get("/changes/" + r.getChange().getId() + "/?O=fffffff");
+    RestResponse rep = adminRestSession.get("/changes/" + r.getChange().getId() + "/?O=ffffffff");
     rep.assertBadRequest();
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseChainOnBehalfOfUploaderIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseChainOnBehalfOfUploaderIT.java
index 88c8499..065e1bf 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RebaseChainOnBehalfOfUploaderIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseChainOnBehalfOfUploaderIT.java
@@ -100,6 +100,22 @@
   }
 
   @Test
+  public void cannotRebaseOnBehalfOfUploaderWithCommitterEmail() throws Exception {
+    Account.Id uploader = accountOperations.newAccount().create();
+    Change.Id changeId = changeOperations.newChange().owner(uploader).create();
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    rebaseInput.committerEmail = "admin@example.com";
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(changeId.get()).rebaseChain(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("committer_email is not supported when rebasing a chain");
+  }
+
+  @Test
   public void rebaseChangeOnBehalfOfUploader_withRebasePermission() throws Exception {
     testRebaseChainOnBehalfOfUploader(Permission.REBASE);
   }
@@ -209,6 +225,76 @@
   }
 
   @Test
+  public void rebaseChainOnBehalfOfUploaderAfterUpdatingPreferredEmailForUploader()
+      throws Exception {
+    // Create a chain of changes for being rebased
+    String uploaderEmailOne = "uploader1@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmailOne).create();
+    Change.Id changeToBeRebased1 =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    Change.Id changeToBeRebased2 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .childOf()
+            .change(changeToBeRebased1)
+            .owner(uploader)
+            .create();
+
+    Change.Id changeToBeRebased3 =
+        changeOperations
+            .newChange()
+            .project(project)
+            .childOf()
+            .change(changeToBeRebased2)
+            .owner(uploader)
+            .create();
+
+    // Change preferred email for the uploader
+    String uploaderEmailTwo = "uploader2@example.com";
+    accountOperations.account(uploader).forUpdate().preferredEmail(uploaderEmailTwo).update();
+
+    // Create, approve and submit the change that will be the new base for the chain that will be
+    // rebased
+    Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Rebase the chain on behalf of the uploader through changeToBeRebased3
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased3.get()).rebaseChain(rebaseInput);
+    assertThat(
+            gApi.changes()
+                .id(changeToBeRebased1.get())
+                .get()
+                .getCurrentRevision()
+                .commit
+                .committer
+                .email)
+        .isEqualTo(uploaderEmailOne);
+    assertThat(
+            gApi.changes()
+                .id(changeToBeRebased2.get())
+                .get()
+                .getCurrentRevision()
+                .commit
+                .committer
+                .email)
+        .isEqualTo(uploaderEmailOne);
+    assertThat(
+            gApi.changes()
+                .id(changeToBeRebased3.get())
+                .get()
+                .getCurrentRevision()
+                .commit
+                .committer
+                .email)
+        .isEqualTo(uploaderEmailOne);
+  }
+
+  @Test
   public void rebaseChainOnBehalfOfUploaderMultipleTimesInARow() throws Exception {
     allowPermissionToAllUsers(Permission.REBASE);
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
index 5ecb5a7..c637916 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
@@ -36,10 +36,12 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestMetricMaker;
+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.RawInputUtil;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
@@ -61,6 +63,7 @@
 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.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -97,6 +100,7 @@
     @Inject protected ProjectOperations projectOperations;
     @Inject protected ExtensionRegistry extensionRegistry;
     @Inject protected TestMetricMaker testMetricMaker;
+    @Inject protected AccountOperations accountOperations;
 
     @FunctionalInterface
     protected interface RebaseCall {
@@ -141,6 +145,82 @@
     }
 
     @Test
+    public void rebaseWithCommitterEmail() throws Exception {
+      // Create three changes with the same parent
+      PushOneCommit.Result r1 = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r3 = createChange();
+
+      // Create new user with a secondary email and with permission to rebase
+      Account.Id userWithSecondaryEmail =
+          accountOperations
+              .newAccount()
+              .preferredEmail("preferred@domain.org")
+              .addSecondaryEmail("secondary@domain.org")
+              .create();
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
+          .update();
+
+      // Approve and submit the r1
+      RevisionApi revision = gApi.changes().id(r1.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Rebase r2 as the new user with its primary email
+      RebaseInput ri = new RebaseInput();
+      ri.committerEmail = "preferred@domain.org";
+      requestScopeOperations.setApiUser(userWithSecondaryEmail);
+      rebaseCallWithInput.call(r2.getChangeId(), ri);
+      assertThat(r2.getChange().getCommitter().getEmailAddress()).isEqualTo(ri.committerEmail);
+
+      // Approve and submit the r3
+      requestScopeOperations.setApiUser(admin.id());
+      revision = gApi.changes().id(r3.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Rebase r2 as the new user with its secondary email
+      ri = new RebaseInput();
+      ri.committerEmail = "secondary@domain.org";
+      requestScopeOperations.setApiUser(userWithSecondaryEmail);
+      rebaseCallWithInput.call(r2.getChangeId(), ri);
+      assertThat(r2.getChange().getCommitter().getEmailAddress()).isEqualTo(ri.committerEmail);
+    }
+
+    @Test
+    public void cannotRebaseWithInvalidCommitterEmail() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result c1 = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result c2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(c1.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Rebase the second change with invalid committer email
+      RebaseInput ri = new RebaseInput();
+      ri.committerEmail = "invalid@example.com";
+      ResourceConflictException thrown =
+          assertThrows(
+              ResourceConflictException.class,
+              () -> rebaseCallWithInput.call(c2.getChangeId(), ri));
+      assertThat(thrown)
+          .hasMessageThat()
+          .isEqualTo(
+              String.format(
+                  "Cannot rebase using committer email '%s' as it is not a registered email of "
+                      + "the user on whose behalf the rebase operation is performed",
+                  ri.committerEmail));
+    }
+
+    @Test
     public void rebaseAbandonedChange() throws Exception {
       PushOneCommit.Result r = createChange();
       String changeId = r.getChangeId();
@@ -214,6 +294,30 @@
     }
 
     @Test
+    public void rebaseChangeAfterUpdatingPreferredEmail() throws Exception {
+      String emailOne = "email1@example.com";
+      Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+      // Create two changes both with the same parent
+      Change.Id c1 = changeOperations.newChange().project(project).owner(testUser).create();
+      Change.Id c2 = changeOperations.newChange().project(project).owner(testUser).create();
+
+      // Approve and submit the first change
+      gApi.changes().id(c1.get()).current().review(ReviewInput.approve());
+      gApi.changes().id(c1.get()).current().submit();
+
+      // Change preferred email for the user
+      String emailTwo = "email2@example.com";
+      accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+      requestScopeOperations.setApiUser(testUser);
+
+      // Rebase the second change
+      gApi.changes().id(c2.get()).rebase();
+      assertThat(gApi.changes().id(c2.get()).get().getCurrentRevision().commit.committer.email)
+          .isEqualTo(emailOne);
+    }
+
+    @Test
     public void cannotRebaseOntoBaseThatIsNotPresentInTargetBranch() throws Exception {
       ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
 
@@ -434,6 +538,12 @@
       String changeId = r2.getChangeId();
       requestScopeOperations.setApiUser(user.id());
       rebaseCall.call(changeId);
+
+      // Verify that the committer has been updated
+      GitPerson committer =
+          gApi.changes().id(r2.getChangeId()).get().getCurrentRevision().commit.committer;
+      assertThat(committer.name).isEqualTo(user.fullName());
+      assertThat(committer.email).isEqualTo(user.email());
     }
 
     @Test
@@ -663,6 +773,87 @@
       assertThat(r1.getPatchSetId().get()).isEqualTo(3);
     }
 
+    private void rebaseWithConflict_strategy(String strategy) throws Exception {
+      String patchSetSubject = "patch set change";
+      String patchSetContent = "patch set content";
+      String baseSubject = "base change";
+      String baseContent = "base content";
+      String expectedContent = strategy.equals("theirs") ? baseContent : patchSetContent;
+
+      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.strategy = strategy;
+
+        testMetricMaker.reset();
+        ChangeInfo changeInfo =
+            gApi.changes().id(changeId).revision(patchSet.name()).rebaseAsInfo(rebaseInput);
+        assertThat(changeInfo.containsGitConflicts).isNull();
+        assertThat(changeInfo.workInProgress).isNull();
+
+        // field1 is on_behalf_of_uploader, field2 is rebase_chain, field3 is allow_conflicts
+        assertThat(testMetricMaker.getCount("change/count_rebases", false, false, false))
+            .isEqualTo(1);
+      }
+      assertThat(wipStateChangedListener.invoked).isFalse();
+      assertThat(wipStateChangedListener.wip).isNull();
+
+      // 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.getCurrentRevision().commit.parents.get(0).commit)
+          .isEqualTo(base.name());
+
+      // Verify that the file content in the created patch set is correct.
+      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);
+      assertThat(fileContent).isEqualTo(expectedContent);
+
+      // 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");
+    }
+
+    @Test
+    public void rebaseWithConflict_strategyAcceptTheirs() throws Exception {
+      rebaseWithConflict_strategy("theirs");
+    }
+
+    @Test
+    public void rebaseWithConflict_strategyAcceptOurs() throws Exception {
+      rebaseWithConflict_strategy("ours");
+    }
+
     @Test
     public void rebaseWithConflict_conflictsAllowed() throws Exception {
       String patchSetSubject = "patch set change";
@@ -982,6 +1173,72 @@
       assertThat(thrown).hasMessageThat().contains("The whole chain is already up to date.");
     }
 
+    @Override
+    @Test
+    public void rebaseWithCommitterEmail() throws Exception {
+      // Create changes with the following hierarchy:
+      // * HEAD
+      //   * r1
+      //   * r2
+
+      PushOneCommit.Result r1 = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result r2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(r1.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Create new user with a secondary email and with permission to rebase
+      Account.Id userWithSecondaryEmail =
+          accountOperations
+              .newAccount()
+              .preferredEmail("preferred@domain.org")
+              .addSecondaryEmail("secondary@domain.org")
+              .create();
+      projectOperations
+          .project(project)
+          .forUpdate()
+          .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
+          .update();
+
+      // Rebase the chain through r2 with the new user and with its secondary email.
+      RebaseInput ri = new RebaseInput();
+      ri.committerEmail = "secondary@domain.org";
+      requestScopeOperations.setApiUser(userWithSecondaryEmail);
+      BadRequestException exception =
+          assertThrows(
+              BadRequestException.class, () -> gApi.changes().id(r2.getChangeId()).rebaseChain(ri));
+      assertThat(exception)
+          .hasMessageThat()
+          .isEqualTo("committer_email is not supported when rebasing a chain");
+    }
+
+    @Override
+    @Test
+    public void cannotRebaseWithInvalidCommitterEmail() throws Exception {
+      // Create two changes both with the same parent
+      PushOneCommit.Result c1 = createChange();
+      testRepo.reset("HEAD~1");
+      PushOneCommit.Result c2 = createChange();
+
+      // Approve and submit the first change
+      RevisionApi revision = gApi.changes().id(c1.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      // Rebase the second change with invalid committer email
+      RebaseInput ri = new RebaseInput();
+      ri.committerEmail = "invalid@example.com";
+      BadRequestException exception =
+          assertThrows(
+              BadRequestException.class, () -> gApi.changes().id(c2.getChangeId()).rebaseChain(ri));
+      assertThat(exception)
+          .hasMessageThat()
+          .isEqualTo("committer_email is not supported when rebasing a chain");
+    }
+
     @Test
     public void rebaseChain() throws Exception {
       // Create changes with the following hierarchy:
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
index 091eee4..7ce3368 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -36,6 +37,7 @@
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
@@ -100,6 +102,103 @@
   }
 
   @Test
+  public void rebaseOnBehalfOfUploaderWithCommitterEmail() throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderPreferredEmail = "uploader.preferred@example.com";
+    String uploaderSecondaryEmail = "uploader.secondary@example.com";
+    Account.Id uploader =
+        accountOperations
+            .newAccount()
+            .preferredEmail(uploaderPreferredEmail)
+            .addSecondaryEmail(uploaderSecondaryEmail)
+            .create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.VIEW_SECONDARY_EMAILS).group(REGISTERED_USERS))
+        .update();
+
+    // Create two changes both with the same parent.
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Rebase the second change on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    rebaseInput.committerEmail = uploaderSecondaryEmail;
+    gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+    assertThat(
+            gApi.changes()
+                .id(changeToBeRebased.get())
+                .get()
+                .getCurrentRevision()
+                .commit
+                .committer
+                .email)
+        .isEqualTo(uploaderSecondaryEmail);
+  }
+
+  @Test
+  public void cannotRebaseOnBehalfOfUploaderWithCommitterEmailWithoutViewSecondaryEmails()
+      throws Exception {
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderPreferredEmail = "uploader.preferred@example.com";
+    String uploaderSecondaryEmail = "uploader.secondary@example.com";
+    Account.Id uploader =
+        accountOperations
+            .newAccount()
+            .preferredEmail(uploaderPreferredEmail)
+            .addSecondaryEmail(uploaderSecondaryEmail)
+            .create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent.
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Rebase the second change on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    rebaseInput.committerEmail = uploaderSecondaryEmail;
+
+    ResourceConflictException exception =
+        assertThrows(
+            ResourceConflictException.class,
+            () -> gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Cannot rebase using committer email '%s'. It can only be done using "
+                    + "the preferred email or the committer email of the uploader",
+                uploaderSecondaryEmail));
+  }
+
+  @Test
   public void cannotRebaseNonCurrentPatchSetOnBehalfOfUploader() throws Exception {
     Account.Id uploader = accountOperations.newAccount().create();
     Change.Id changeId = changeOperations.newChange().owner(uploader).create();
@@ -254,6 +353,41 @@
   }
 
   @Test
+  public void rebaseChangeOnBehalfOfUploaderAfterUpdatingPreferredEmailForUploader()
+      throws Exception {
+    String uploaderEmailOne = "uploader1@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmailOne).create();
+
+    // Create two changes both with the same parent
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+    // Change preferred email for the uploader
+    String uploaderEmailTwo = "uploader2@example.com";
+    accountOperations.account(uploader).forUpdate().preferredEmail(uploaderEmailTwo).update();
+
+    // Rebase the second change on behalf of the uploader
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+    assertThat(
+            gApi.changes()
+                .id(changeToBeRebased.get())
+                .get()
+                .getCurrentRevision()
+                .commit
+                .committer
+                .email)
+        .isEqualTo(uploaderEmailOne);
+  }
+
+  @Test
   public void rebaseChangeOnBehalfOfUploaderMultipleTimesInARow() throws Exception {
     allowPermissionToAllUsers(Permission.REBASE);
 
@@ -1107,6 +1241,86 @@
   }
 
   @Test
+  public void submittedWithRebaserApprovalMetricIsNotIncreasedIfANonRebaserApprovalIsPresent()
+      throws Exception {
+    allowVotingOnCodeReviewToAllUsers();
+
+    createVerifiedLabel();
+    allowVotingOnVerifiedToAllUsers();
+
+    // Require a Code-Review approval from a non-uploader for submit.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .upsertSubmitRequirement(
+              SubmitRequirement.builder()
+                  .setName(TestLabels.verified().getName())
+                  .setSubmittabilityExpression(
+                      SubmitRequirementExpression.create(
+                          String.format("label:%s=MAX", TestLabels.verified().getName())))
+                  .setAllowOverrideInChildProjects(false)
+                  .build());
+      u.getConfig()
+          .upsertSubmitRequirement(
+              SubmitRequirement.builder()
+                  .setName(TestLabels.codeReview().getName())
+                  .setSubmittabilityExpression(
+                      SubmitRequirementExpression.create(
+                          String.format(
+                              "label:%s=MAX,user=non_uploader", TestLabels.codeReview().getName())))
+                  .setAllowOverrideInChildProjects(false)
+                  .build());
+      u.save();
+    }
+
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes()
+        .id(changeToBeTheNewBase.get())
+        .current()
+        .review(ReviewInput.approve().label(TestLabels.verified().getName(), 1));
+    testMetricMaker.reset();
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+    assertThat(testMetricMaker.getCount("change/submitted_with_rebaser_approval")).isEqualTo(0);
+
+    // Rebase it on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+    // Approve the change as the rebaser.
+    gApi.changes()
+        .id(changeToBeRebased.get())
+        .current()
+        .review(ReviewInput.approve().label(TestLabels.verified().getName(), 1));
+
+    // Approve the change as another user.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeRebased.get()).current().review(ReviewInput.approve());
+
+    // Due to the second approval the change would also be submittable if the approval of the
+    // rebaser would be ignored due to the rebaser being the uploader.
+    allowPermissionToAllUsers(Permission.SUBMIT);
+    testMetricMaker.reset();
+    gApi.changes().id(changeToBeRebased.get()).current().submit();
+    assertThat(testMetricMaker.getCount("change/submitted_with_rebaser_approval")).isEqualTo(0);
+  }
+
+  @Test
   public void testCountRebasesMetric() throws Exception {
     allowPermissionToAllUsers(Permission.REBASE);
 
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index 4855ba4..7c50e93 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -225,6 +225,12 @@
     List<ChangeMessageInfo> sourceMessages =
         new ArrayList<>(gApi.changes().id(r.getChangeId()).get().messages);
     assertThat(sourceMessages).hasSize(3);
+    // Publishing creates a revert message
+    gApi.changes().id(revertChange.changeId).setReadyForReview();
+    sourceMessages = new ArrayList<>(gApi.changes().id(r.getChangeId()).get().messages);
+    assertThat(sourceMessages).hasSize(4);
+    assertThat(sourceMessages.get(3).message)
+        .isEqualTo("Created a revert of this change as " + revertChange.changeId);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
index 2fe7038..b5416aa 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
@@ -46,7 +46,7 @@
 import com.google.gerrit.entities.SubmitRequirementExpressionResult;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
+import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -65,7 +65,7 @@
 public class SubmitRequirementPredicateIT extends AbstractDaemonTest {
 
   @Inject private RequestScopeOperations requestScopeOperations;
-  @Inject private SubmitRequirementsEvaluator submitRequirementsEvaluator;
+  @Inject private SubmitRequirementsEvaluatorImpl submitRequirementsEvaluator;
   @Inject private ChangeOperations changeOperations;
   @Inject private ProjectOperations projectOperations;
   @Inject private AccountOperations accountOperations;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
index da89c9a..34e521c 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
@@ -16,7 +16,9 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.entities.LabelFunction.NO_BLOCK;
 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.labelBuilder;
 import static com.google.gerrit.server.project.testing.TestLabels.value;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -589,6 +591,34 @@
   }
 
   @Test
+  public void overriddenSubmitRequirementMissingCodeReviewVote_submitsWithoutDiff()
+      throws Exception {
+    // Set Code-Review to optional
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .upsertLabelType(
+              label(
+                      "Code-Review",
+                      value(1, "Positive"),
+                      value(0, "No score"),
+                      value(-1, "Negative"))
+                  .toBuilder()
+                  .setFunction(NO_BLOCK)
+                  .build());
+      u.save();
+    }
+
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    changeOperations.change(changeId).newPatchset().create();
+
+    // Submitted without Code-Review approval
+    gApi.changes().id(changeId.get()).current().submit();
+
+    assertThat(Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message)
+        .isEqualTo("Change has been successfully merged");
+  }
+
+  @Test
   public void diffChangeMessageOnSubmitWithStickyVote_noChanges() throws Exception {
     Change.Id changeId = changeOperations.newChange().project(project).create();
     gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 6dbbe9a..db12e85 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -84,6 +84,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupIncludeCache;
@@ -98,7 +99,6 @@
 import com.google.gerrit.server.group.db.InternalGroupCreation;
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.index.group.StalenessChecker;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.GerritJUnit.ThrowingRunnable;
diff --git a/javatests/com/google/gerrit/acceptance/api/project/AccessReviewIT.java b/javatests/com/google/gerrit/acceptance/api/project/AccessReviewIT.java
index 553650a..c03a710 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/AccessReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/AccessReviewIT.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.acceptance.api.project;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.truth.ConfigSubject.assertThat;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index 59dafc6..f02c900 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -59,6 +59,7 @@
   private Project.NameKey normalProject;
   private Project.NameKey secretProject;
   private Project.NameKey secretRefProject;
+  private AccountGroup.UUID privilegedGroupUuid;
   private TestAccount privilegedUser;
 
   @Before
@@ -66,8 +67,7 @@
     normalProject = projectOperations.newProject().create();
     secretProject = projectOperations.newProject().create();
     secretRefProject = projectOperations.newProject().create();
-    AccountGroup.UUID privilegedGroupUuid =
-        groupOperations.newGroup().name(name("privilegedGroup")).create();
+    privilegedGroupUuid = groupOperations.newGroup().name(name("privilegedGroup")).create();
 
     privilegedUser = accountCreator.create("privilegedUser", "snowden@nsa.gov", "Ed Snowden", null);
     groupOperations.group(privilegedGroupUuid).forUpdate().addMember(privilegedUser.id()).update();
@@ -239,7 +239,9 @@
                 ImmutableList.of(
                     "'user1' can perform 'read' with force=false on project '"
                         + normalProject.get()
-                        + "' for ref 'refs/heads/*'",
+                        + "' for ref 'refs/heads/*' (allowed for group '"
+                        + SystemGroupBackend.ANONYMOUS_USERS.get()
+                        + "' by rule 'group Anonymous Users')",
                     "'user1' cannot perform 'viewPrivateChanges' with force=false on project '"
                         + normalProject.get()
                         + "' for ref 'refs/heads/master'")),
@@ -251,7 +253,9 @@
                 ImmutableList.of(
                     "'user1' can perform 'read' with force=false on project '"
                         + normalProject.get()
-                        + "' for ref 'refs/heads/*'")),
+                        + "' for ref 'refs/heads/*' (allowed for group '"
+                        + SystemGroupBackend.ANONYMOUS_USERS.get()
+                        + "' by rule 'group Anonymous Users')")),
             // Test 3
             TestCase.project(
                 user.email(),
@@ -273,7 +277,13 @@
                 ImmutableList.of(
                     "'user1' can perform 'read' with force=false on project '"
                         + secretRefProject.get()
-                        + "' for ref 'refs/heads/*'",
+                        + "' for ref 'refs/heads/*' (allowed for group '"
+                        + SystemGroupBackend.REGISTERED_USERS.get()
+                        // if the permission was assigned through ProjectOperations the local group
+                        // name is set to the UUID
+                        + "' by rule 'group "
+                        + SystemGroupBackend.REGISTERED_USERS.get()
+                        + "')",
                     "'user1' cannot perform 'read' with force=false on project '"
                         + secretRefProject.get()
                         + "' for ref 'refs/heads/secret/master' because this permission is"
@@ -287,10 +297,22 @@
                 ImmutableList.of(
                     "'privilegedUser' can perform 'read' with force=false on project '"
                         + secretRefProject.get()
-                        + "' for ref 'refs/heads/*'",
+                        + "' for ref 'refs/heads/*' (allowed for group '"
+                        + SystemGroupBackend.REGISTERED_USERS.get()
+                        // if the permission was assigned through ProjectOperations the local group
+                        // name is set to the UUID
+                        + "' by rule 'group "
+                        + SystemGroupBackend.REGISTERED_USERS.get()
+                        + "')",
                     "'privilegedUser' can perform 'read' with force=false on project '"
                         + secretRefProject.get()
-                        + "' for ref 'refs/heads/secret/master'")),
+                        + "' for ref 'refs/heads/secret/master' (allowed for group '"
+                        + privilegedGroupUuid.get()
+                        // if the permission was assigned through ProjectOperations the local group
+                        // name is set to the UUID
+                        + "' by rule 'group "
+                        + privilegedGroupUuid.get()
+                        + "')")),
             // Test 6
             TestCase.projectRef(
                 privilegedUser.email(),
@@ -300,7 +322,9 @@
                 ImmutableList.of(
                     "'privilegedUser' can perform 'read' with force=false on project '"
                         + normalProject.get()
-                        + "' for ref 'refs/heads/*'")),
+                        + "' for ref 'refs/heads/*' (allowed for group '"
+                        + SystemGroupBackend.ANONYMOUS_USERS.get()
+                        + "' by rule 'group Anonymous Users')")),
             // Test 7
             TestCase.projectRef(
                 privilegedUser.email(),
@@ -310,7 +334,13 @@
                 ImmutableList.of(
                     "'privilegedUser' can perform 'read' with force=false on project '"
                         + secretProject.get()
-                        + "' for ref 'refs/*'")),
+                        + "' for ref 'refs/*' (allowed for group '"
+                        + privilegedGroupUuid.get()
+                        // if the permission was assigned through ProjectOperations the local group
+                        // name is set to the UUID
+                        + "' by rule 'group "
+                        + privilegedGroupUuid.get()
+                        + "')")),
             // Test 8
             TestCase.projectRefPerm(
                 privilegedUser.email(),
@@ -321,11 +351,19 @@
                 ImmutableList.of(
                     "'privilegedUser' can perform 'read' with force=false on project '"
                         + normalProject.get()
-                        + "' for ref 'refs/heads/*'",
+                        + "' for ref 'refs/heads/*' (allowed for group '"
+                        + SystemGroupBackend.ANONYMOUS_USERS.get()
+                        + "' by rule 'group Anonymous Users')",
                     "'privilegedUser' can perform 'viewPrivateChanges' with force=false on project"
                         + " '"
                         + normalProject.get()
-                        + "' for ref 'refs/heads/master'")),
+                        + "' for ref 'refs/heads/master' (allowed for group '"
+                        + privilegedGroupUuid.get()
+                        // if the permission was assigned through ProjectOperations the local group
+                        // name is set to the UUID
+                        + "' by rule 'group "
+                        + privilegedGroupUuid.get()
+                        + "')")),
             // Test 9
             TestCase.projectRefPerm(
                 privilegedUser.email(),
@@ -336,11 +374,19 @@
                 ImmutableList.of(
                     "'privilegedUser' can perform 'read' with force=false on project '"
                         + normalProject.get()
-                        + "' for ref 'refs/heads/*'",
+                        + "' for ref 'refs/heads/*' (allowed for group '"
+                        + SystemGroupBackend.ANONYMOUS_USERS.get()
+                        + "' by rule 'group Anonymous Users')",
                     "'privilegedUser' can perform 'forgeServerAsCommitter' with force=false on"
                         + " project '"
                         + normalProject.get()
-                        + "' for ref 'refs/heads/master'")));
+                        + "' for ref 'refs/heads/master' (allowed for group '"
+                        + privilegedGroupUuid.get()
+                        // if the permission was assigned through ProjectOperations the local group
+                        // name is set to the UUID
+                        + "' by rule 'group "
+                        + privilegedGroupUuid.get()
+                        + "')")));
 
     for (TestCase tc : inputs) {
       String in = newGson().toJson(tc.input);
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
index f4599e6..0919086 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
@@ -29,8 +29,13 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.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.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
@@ -58,6 +63,9 @@
 @NoHttpd
 public class CommitIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
+  @Inject private AccountOperations accountOperations;
+  @Inject private ChangeOperations changeOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   @Test
   public void getCommitInfo() throws Exception {
@@ -190,6 +198,40 @@
   }
 
   @Test
+  public void cherryPickCommitAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Create target branch to cherry-pick to
+    String destBranch = "foo";
+    createBranch(BranchNameKey.create(project, destBranch));
+
+    // Create change to cherry-pick
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("content")
+            .owner(testUser)
+            .create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Cherry-pick the change
+    String commit = gApi.changes().id(changeId.get()).get().getCurrentRevision().commit.commit;
+    CherryPickInput input = new CherryPickInput();
+    input.destination = destBranch;
+    input.message = "cherry-pick to foo branch";
+    ChangeInfo cherryPickResult =
+        gApi.projects().name(project.get()).commit(commit).cherryPick(input).get();
+    assertThat(cherryPickResult.getCurrentRevision().commit.committer.email).isEqualTo(emailOne);
+  }
+
+  @Test
   public void cherryPickWithoutMessageOtherBranch() throws Exception {
     String destBranch = "foo";
     createBranch(BranchNameKey.create(project, destBranch));
@@ -334,7 +376,9 @@
     Iterator<ChangeMessageInfo> messageIterator = cherryPickResult.messages.iterator();
 
     assertThat(messageIterator.next().message).isEqualTo("Uploaded patch set 1.");
-    assertThat(messageIterator.next().message).isEqualTo("Uploaded patch set 2.");
+    String expectedMessage =
+        String.format("Patch Set 2: Cherry Picked from commit %s.", commitToCherryPick);
+    assertThat(messageIterator.next().message).isEqualTo(expectedMessage);
     // Cherry-pick of is not set, because the source change was not provided.
     assertThat(cherryPickResult.cherryPickOfChange).isNull();
     assertThat(cherryPickResult.cherryPickOfPatchSet).isNull();
@@ -384,8 +428,11 @@
     Iterator<ChangeMessageInfo> messageIterator = cherryPickResult.messages.iterator();
 
     assertThat(messageIterator.next().message).isEqualTo("Uploaded patch set 1.");
-    assertThat(messageIterator.next().message).isEqualTo("Uploaded patch set 2.");
-    assertThat(messageIterator.next().message).isEqualTo("Uploaded patch set 3.");
+    assertThat(messageIterator.next().message)
+        .isEqualTo("Patch Set 2: Cherry Picked from branch master.");
+    String expectedMessage =
+        String.format("Patch Set 3: Cherry Picked from commit %s.", commitToCherryPick.getName());
+    assertThat(messageIterator.next().message).isEqualTo(expectedMessage);
     // Cherry-pick was reset to empty value.
     assertThat(cherryPickResult._number).isEqualTo(existingDestChange._number);
     assertThat(cherryPickResult.cherryPickOfChange).isNull();
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index f997c77..a93c0a5 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -1209,6 +1209,31 @@
         .isNull();
   }
 
+  @Test
+  public void queryProjectsLimit() throws Exception {
+    for (int i = 0; i < 3; i++) {
+      projectOperations.newProject().create();
+    }
+    List<ProjectInfo> resultsLimited = gApi.projects().query().withLimit(1).get();
+    List<ProjectInfo> resultsUnlimited = gApi.projects().query().get();
+    assertThat(resultsLimited).hasSize(1);
+    assertThat(resultsUnlimited.size()).isAtLeast(3);
+  }
+
+  @Test
+  @GerritConfig(name = "index.defaultLimit", value = "2")
+  public void queryProjectsLimitDefault() throws Exception {
+    for (int i = 0; i < 4; i++) {
+      projectOperations.newProject().create();
+    }
+    List<ProjectInfo> resultsLimited = gApi.projects().query().withLimit(1).get();
+    List<ProjectInfo> resultsUnlimited = gApi.projects().query().get();
+    List<ProjectInfo> resultsLimitedAboveDefault = gApi.projects().query().withLimit(3).get();
+    assertThat(resultsLimited).hasSize(1);
+    assertThat(resultsUnlimited).hasSize(2);
+    assertThat(resultsLimitedAboveDefault).hasSize(3);
+  }
+
   private CommentLinkInfo commentLinkInfo(String name, String match, String link) {
     CommentLinkInfo info = new CommentLinkInfo();
     info.name = name;
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/ApplyProvidedFixIT.java b/javatests/com/google/gerrit/acceptance/api/revision/ApplyProvidedFixIT.java
index 7c0b713..b9ef0bf 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/ApplyProvidedFixIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/ApplyProvidedFixIT.java
@@ -25,7 +25,12 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
+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.entities.Account;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
@@ -52,6 +57,9 @@
 
 public class ApplyProvidedFixIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
+  @Inject private AccountOperations accountOperations;
+  @Inject private ChangeOperations changeOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   private static final String FILE_NAME = "file_to_fix.txt";
   private static final String FILE_NAME2 = "another_file_to_fix.txt";
@@ -95,6 +103,35 @@
   }
 
   @Test
+  public void applyProvidedFixAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Create change
+    Change.Id change =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file(FILE_NAME)
+            .content(FILE_CONTENT)
+            .owner(testUser)
+            .create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Apply fix
+    ApplyProvidedFixInput applyProvidedFixInput =
+        createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
+    gApi.changes().id(change.get()).current().applyFix(applyProvidedFixInput);
+
+    EditInfo editInfo = gApi.changes().id(change.get()).edit().get().orElseThrow();
+    assertThat(editInfo.commit.committer.email).isEqualTo(emailOne);
+  }
+
+  @Test
   public void applyProvidedFixRestAPItestForASimpleFix() throws Exception {
     ApplyProvidedFixInput applyProvidedFixInput =
         createApplyProvidedFixInput(FILE_NAME, "Modified content", 3, 1, 3, 3);
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index b570466..7eb33b6 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -24,10 +24,12 @@
 import static com.google.gerrit.extensions.common.testing.FileInfoSubject.assertThat;
 import static com.google.gerrit.git.ObjectIds.abbreviateName;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toMap;
 
 import com.google.common.base.Joiner;
+import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -36,8 +38,10 @@
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.FileApi;
@@ -47,6 +51,7 @@
 import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
+import com.google.gerrit.extensions.common.testing.ContentEntrySubject;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.webui.EditWebLink;
@@ -91,6 +96,7 @@
 
   @Inject private ExtensionRegistry extensionRegistry;
   @Inject private DiffOperations diffOperations;
+  @Inject private ChangeOperations changeOperations;
   @Inject private ProjectOperations projectOperations;
 
   private boolean intraline;
@@ -3034,6 +3040,145 @@
     assertThat(e).hasMessageThat().isEqualTo("edit not allowed as base");
   }
 
+  @Test
+  public void diffForAddedBinaryFile() throws Exception {
+    String imageFileName = "an_image.png";
+    byte[] imageBytes = createRgbImage(255, 0, 0);
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .file(imageFileName)
+            .content(new String(imageBytes, UTF_8))
+            .create();
+
+    DiffInfo diffInfo = gApi.changes().id(changeId.get()).current().file(imageFileName).diff();
+
+    assertThat(diffInfo).binary().isTrue();
+    assertThat(diffInfo).content().isEmpty();
+    assertThat(diffInfo).diffHeader().contains("Binary files differ");
+    assertThat(diffInfo).metaA().isNull();
+    assertThat(diffInfo).metaB().isNotNull();
+    assertThat(diffInfo).webLinks().isNull();
+  }
+
+  @Test
+  public void diffForModifiedBinaryFile() throws Exception {
+    String imageFileName = "an_image.png";
+    byte[] imageBytes = createRgbImage(255, 0, 0);
+    Change.Id changeId1 =
+        changeOperations
+            .newChange()
+            .file(imageFileName)
+            .content(new String(imageBytes, UTF_8))
+            .create();
+
+    byte[] newImageBytes = createRgbImage(0, 255, 0);
+    Change.Id changeId2 =
+        changeOperations
+            .newChange()
+            .childOf()
+            .change(changeId1)
+            .file(imageFileName)
+            .content(new String(newImageBytes, UTF_8))
+            .create();
+
+    DiffInfo diffInfo = gApi.changes().id(changeId2.get()).current().file(imageFileName).diff();
+
+    assertThat(diffInfo).binary().isTrue();
+
+    // All fields in the contentEntry are null, except the 'skip' field. It's probably a bug that
+    // this is set for binary files.
+    ContentEntrySubject contentEntry = assertThat(diffInfo).content().onlyElement();
+    contentEntry.linesOfA().isNull();
+    contentEntry.linesOfB().isNull();
+    contentEntry.commonLines().isNull();
+
+    assertThat(diffInfo).diffHeader().contains("Binary files differ");
+    assertThat(diffInfo).metaA().isNotNull();
+    assertThat(diffInfo).metaB().isNotNull();
+    assertThat(diffInfo).webLinks().isNull();
+  }
+
+  @Test
+  public void diffForDeletedBinaryFile() throws Exception {
+    String imageFileName = "an_image.png";
+    byte[] imageBytes = createRgbImage(255, 0, 0);
+    Change.Id changeId1 =
+        changeOperations
+            .newChange()
+            .file(imageFileName)
+            .content(new String(imageBytes, UTF_8))
+            .create();
+
+    Change.Id changeId2 =
+        changeOperations
+            .newChange()
+            .childOf()
+            .change(changeId1)
+            .file(imageFileName)
+            .delete()
+            .create();
+
+    DiffInfo diffInfo = gApi.changes().id(changeId2.get()).current().file(imageFileName).diff();
+
+    assertThat(diffInfo).binary().isTrue();
+
+    // All fields in the contentEntry are null, except the 'skip' field. It's probably a bug that
+    // this is set for binary files.
+    ContentEntrySubject contentEntry = assertThat(diffInfo).content().onlyElement();
+    contentEntry.linesOfA().isNull();
+    contentEntry.linesOfB().isNull();
+    contentEntry.commonLines().isNull();
+
+    assertThat(diffInfo).diffHeader().contains("Binary files differ");
+    assertThat(diffInfo).metaA().isNotNull();
+    assertThat(diffInfo).metaB().isNull();
+    assertThat(diffInfo).webLinks().isNull();
+  }
+
+  @Test
+  public void diffForBinaryFileThatIsNotTouchedInTheChange() throws Exception {
+    String imageFileName1 = "an_image.png";
+    byte[] imageBytes1 = createRgbImage(255, 0, 0);
+    String imageContent1 = new String(imageBytes1, UTF_8);
+    Change.Id changeId1 =
+        changeOperations.newChange().file(imageFileName1).content(imageContent1).create();
+
+    String imageFileName2 = "another_image.png";
+    byte[] imageBytes2 = createRgbImage(0, 255, 0);
+    Change.Id changeId2 =
+        changeOperations
+            .newChange()
+            .childOf()
+            .change(changeId1)
+            .file(imageFileName2)
+            .content(new String(imageBytes2, UTF_8))
+            .create();
+
+    // Since file imageFileName1 was not touched in the second change, trying to get the diff for it
+    // should probably fail with '404 Not Found'.
+    DiffInfo diffInfo = gApi.changes().id(changeId2.get()).current().file(imageFileName1).diff();
+
+    // This should be detected as a binary file, but it isn't.
+    assertThat(diffInfo).binary().isNull();
+
+    // For binary files linesOfA, linesOfB and commonLines are expected to be null, but the content
+    // of the binary file is returned as common lines.
+    ContentEntrySubject contentEntry = assertThat(diffInfo).content().onlyElement();
+    contentEntry.linesOfA().isNull();
+    contentEntry.linesOfB().isNull();
+    contentEntry
+        .commonLines()
+        .containsExactlyElementsIn(Splitter.on("\n").splitToList(imageContent1));
+
+    // For binary file the header list should contain "Binary files differ", but it doesn't.
+    assertThat(diffInfo).diffHeader().isNull();
+
+    assertThat(diffInfo).metaA().isNotNull();
+    assertThat(diffInfo).metaB().isNotNull();
+    assertThat(diffInfo).webLinks().isNull();
+  }
+
   private Registration newEditWebLink() {
     EditWebLink webLink =
         new EditWebLink() {
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index f50df60..05474cd 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -49,6 +49,8 @@
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.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.entities.Account;
@@ -58,6 +60,7 @@
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
@@ -70,7 +73,9 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 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.ListChangesOption;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -84,6 +89,7 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.MergeableInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.common.RevisionInfo.ParentInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -108,6 +114,7 @@
 import java.text.SimpleDateFormat;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
@@ -128,6 +135,8 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private AccountOperations accountOperations;
+  @Inject private ChangeOperations changeOperations;
 
   @Test
   public void reviewTriplet() throws Exception {
@@ -352,6 +361,40 @@
   }
 
   @Test
+  public void cherryPickAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Create target branch to cherry-pick to
+    String branch = "foo";
+    gApi.projects().name(project.get()).branch(branch).create(new BranchInput());
+
+    // Create change to cherry-pick
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("content")
+            .owner(testUser)
+            .create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Cherry-pick the change
+    String commit = gApi.changes().id(changeId.get()).get().getCurrentRevision().commit.commit;
+    CherryPickInput input = new CherryPickInput();
+    input.destination = branch;
+    input.message = "cherry-pick to foo branch";
+    ChangeInfo changeInfo =
+        gApi.changes().id(changeId.get()).revision(commit).cherryPick(input).get();
+    assertThat(changeInfo.getCurrentRevision().commit.committer.email).isEqualTo(emailOne);
+  }
+
+  @Test
   public void cherryPickWithoutMessage() throws Exception {
     String branch = "foo";
 
@@ -405,6 +448,62 @@
   }
 
   @Test
+  public void cherryPickWithUnRegisteredCommitterEmail() throws Exception {
+    // Create change to cherry-pick
+    PushOneCommit.Result r = pushTo("refs/for/master");
+
+    // Create target branch to cherry-pick to
+    String destination = "foo";
+    gApi.projects().name(project.get()).branch(destination).create(new BranchInput());
+
+    // Cherry-pick the change
+    CherryPickInput in = new CherryPickInput();
+    in.destination = destination;
+    in.message = "it goes to foo branch";
+    in.committerEmail = "secondary@example.org";
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).cherryPick(in));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "Cannot cherry-pick using committer email "
+                + in.committerEmail
+                + ", as it is not among the registered emails of account "
+                + admin.id().get());
+  }
+
+  @Test
+  public void cherryPickWithNonPreferredCommitterEmail() throws Exception {
+    // Create change to cherry-pick
+    PushOneCommit.Result r = pushTo("refs/for/master");
+
+    // Create target branch to cherry-pick to
+    String destination = "foo";
+    gApi.projects().name(project.get()).branch(destination).create(new BranchInput());
+
+    // Create a user with secondary email
+    Account.Id userWithSecondaryEmail =
+        accountOperations
+            .newAccount()
+            .preferredEmail("preferred@example.org")
+            .addSecondaryEmail("secondary@example.org")
+            .create();
+    requestScopeOperations.setApiUser(userWithSecondaryEmail);
+
+    // Cherry-pick the change
+    CherryPickInput in = new CherryPickInput();
+    in.destination = destination;
+    in.message = "it goes to foo branch";
+    in.committerEmail = "secondary@example.org";
+    ChangeApi cherry =
+        gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).cherryPick(in);
+    assertThat(cherry.get().getCurrentRevision().commit.committer.email)
+        .isEqualTo(in.committerEmail);
+  }
+
+  @Test
   public void cherryPickWithNoTopic() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master");
     CherryPickInput in = new CherryPickInput();
@@ -496,7 +595,7 @@
     assertThat(cherryInfo._number).isEqualTo(change.get()._number);
     assertThat(cherryInfo.cherryPickOfPatchSet).isEqualTo(1);
     assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 1.");
-    assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 2.");
+    assertThat(cherryIt.next().message).isEqualTo("Patch Set 2: Cherry Picked from branch master.");
   }
 
   @Test
@@ -532,7 +631,7 @@
     assertThat(cherryInfo.messages).hasSize(2);
     Iterator<ChangeMessageInfo> cherryIt = cherryInfo.messages.iterator();
     assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 1.");
-    assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 2.");
+    assertThat(cherryIt.next().message).isEqualTo("Patch Set 2: Cherry Picked from branch master.");
 
     // Parent of change 2 should now be the change that was merged, i.e.
     // change 2 is rebased onto the head of the master branch.
@@ -694,6 +793,31 @@
   }
 
   @Test
+  public void cherryPickToExistingChangeWithAllowConflictsSetsWIPOnConflict() throws Exception {
+    String tip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
+
+    String destBranch = "foo";
+    createBranch(BranchNameKey.create(project, destBranch));
+    PushOneCommit.Result existingChange =
+        createChange(testRepo, destBranch, SUBJECT, FILE_NAME, "some content", null);
+
+    testRepo.reset(tip);
+    PushOneCommit.Result srcChange =
+        createChange(testRepo, "master", SUBJECT, FILE_NAME, "other content", null);
+
+    CherryPickInput input = new CherryPickInput();
+    input.destination = destBranch;
+    input.base = existingChange.getCommit().name();
+    input.message = "cherry-pick to foo" + "\n\nChange-Id: " + existingChange.getChangeId();
+    input.allowConflicts = true;
+    ChangeInfo changeInfo =
+        change(srcChange).revision(srcChange.getCommit().name()).cherryPickAsInfo(input);
+
+    assertThat(changeInfo.containsGitConflicts).isTrue();
+    assertThat(changeInfo.workInProgress).isTrue();
+  }
+
+  @Test
   public void cherryPickWithValidationOptions() throws Exception {
     PushOneCommit.Result r = createChange();
 
@@ -1646,6 +1770,37 @@
   }
 
   @Test
+  public void targetBranch_isSetForEachPatchSet() throws Exception {
+    createBranch(BranchNameKey.create(project, "foo"));
+    createBranch(BranchNameKey.create(project, "bar"));
+
+    // Upload five patch-sets: patch-set 1 is destined for branch master, patch-set 2 destined for
+    // branch foo, patch-set 3 with no change, and patch-set 4 for branch bar and patch-set 5 with
+    // no change in branch. Make sure the targetBranch field is set correctly on each revision.
+
+    // PS1 - branch = master
+    PushOneCommit.Result r1 = createChange();
+    // PS2 - branch = foo
+    PushOneCommit.Result r2 = amendChange(r1.getChangeId());
+    move(r1.getChangeId(), "foo");
+    // PS3 - branch = foo
+    PushOneCommit.Result r3 = amendChange(r1.getChangeId(), "refs/for/foo", admin, testRepo);
+    // PS4 - branch = bar
+    PushOneCommit.Result r4 = amendChange(r1.getChangeId(), "refs/for/foo", admin, testRepo);
+    move(r1.getChangeId(), "bar");
+    // PS5 - branch = bar
+    PushOneCommit.Result r5 = amendChange(r1.getChangeId(), "refs/for/bar", admin, testRepo);
+
+    Map<String, RevisionInfo> revisions = gApi.changes().id(r1.getChangeId()).get().revisions;
+    assertThat(revisions).hasSize(5);
+    assertThat(revisions.get(r1.getCommit().name()).branch).isEqualTo("refs/heads/master");
+    assertThat(revisions.get(r2.getCommit().name()).branch).isEqualTo("refs/heads/foo");
+    assertThat(revisions.get(r3.getCommit().name()).branch).isEqualTo("refs/heads/foo");
+    assertThat(revisions.get(r4.getCommit().name()).branch).isEqualTo("refs/heads/bar");
+    assertThat(revisions.get(r5.getCommit().name()).branch).isEqualTo("refs/heads/bar");
+  }
+
+  @Test
   public void setDescriptionNotAllowedWithoutPermission() throws Exception {
     PushOneCommit.Result r = createChange();
     assertDescription(r, "");
@@ -1688,6 +1843,34 @@
   }
 
   @Test
+  @GerritConfig(name = "change.maxFileSizeDownload", value = "10")
+  public void content_maxFileSizeDownload() throws Exception {
+    Map<String, String> files =
+        ImmutableMap.of("dir/file1.txt", " 9 bytes ", "dir/file2.txt", "11 bytes xx");
+    PushOneCommit.Result result =
+        pushFactory.create(admin.newIdent(), testRepo, SUBJECT, files).to("refs/for/master");
+    result.assertOkStatus();
+
+    // 9 bytes should be fine, because the limit is 10 bytes.
+    assertContent(result, "dir/file1.txt", " 9 bytes ");
+
+    // 11 bytes should throw, because the limit is 10 bytes.
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                gApi.changes()
+                    .id(result.getChangeId())
+                    .revision(result.getCommit().name())
+                    .file("dir/file2.txt")
+                    .content());
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo(
+            "File too big. File size: 11 bytes. Configured 'maxFileSizeDownload' limit: 10 bytes.");
+  }
+
+  @Test
   public void patchsetLevelContentDoesNotExist() throws Exception {
     PushOneCommit.Result change = createChange();
     assertThrows(
@@ -2119,8 +2302,257 @@
     assertThat(m.body()).contains(admin.fullName() + " has uploaded a new patch set (#2).");
   }
 
+  @Test
+  public void parentData_notPopulatedIfParentsChangeOptionIsNotSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    List<ParentInfo> parentsData =
+        getRevisionWithoutParents(r.getChangeId(), /* patchSetNumber= */ 1).parentsData;
+    assertThat(parentsData).isNull();
+  }
+
+  @Test
+  public void parentData_parentIsATargetBranch() throws Exception {
+    RevCommit initialCommit = getHead(repo(), "HEAD");
+    PushOneCommit.Result r = createChange();
+    List<ParentInfo> parentsData =
+        getRevisionWithParents(r.getChangeId(), /* patchSetNumber= */ 1).parentsData;
+    assertThat(parentsData).hasSize(1);
+    assertParentIsInTargetBranch(
+        parentsData.get(0), "refs/heads/master", initialCommit.getId().name());
+  }
+
+  @Test
+  public void parentData_parentIsATargetBranch_changeMoved() throws Exception {
+    createBranch(BranchNameKey.create(project, "foo"));
+
+    // PS1 was set to be merged in refs/heads/master
+    RevCommit initialCommit = getHead(repo(), "HEAD");
+    PushOneCommit.Result r = createChange();
+
+    // Create PS2, and set target branch to refs/heads/foo
+    amendChange(r.getChangeId());
+    move(r.getChangeId(), "foo");
+
+    // PS1 is based on refs/heads/master
+    List<ParentInfo> parentsData =
+        getRevisionWithParents(r.getChangeId(), /* patchSetNumber= */ 1).parentsData;
+    assertThat(parentsData).hasSize(1);
+    assertParentIsInTargetBranch(parentsData.get(0), "refs/heads/master", initialCommit.name());
+
+    // PS2 is based on refs/heads/foo
+    parentsData = getRevisionWithParents(r.getChangeId(), /* patchSetNumber= */ 2).parentsData;
+    assertThat(parentsData).hasSize(1);
+    assertParentIsInTargetBranch(parentsData.get(0), "refs/heads/foo", initialCommit.name());
+  }
+
+  @Test
+  public void parentData_parentIsAPendingChange() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange();
+
+    List<ParentInfo> parentsData =
+        getRevisionWithParents(r2.getChangeId(), /* patchSetNumber= */ 1).parentsData;
+    assertThat(parentsData).hasSize(1);
+    assertParentIsChange(
+        parentsData.get(0),
+        r1.getCommit().getId(),
+        r1.getChangeId(),
+        r1.getChange().change().getChangeId(),
+        /* patchSetNumber= */ 1,
+        /* parentChangeStatus= */ "NEW",
+        /* isParentCommitMergedInTargetBranch= */ false);
+  }
+
+  @Test
+  public void parentData_parentIsANonLastPatchSet_parentIsPending() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange();
+
+    // Create PS2 of the first change
+    testRepo.reset(r1.getCommit());
+    amendChange(r1.getChangeId());
+    assertThat(gApi.changes().id(r1.getChangeId()).get().revisions).hasSize(2);
+
+    List<ParentInfo> parentsData =
+        getRevisionWithParents(r2.getChangeId(), /* patchSetNumber= */ 1).parentsData;
+    assertThat(parentsData).hasSize(1);
+    assertParentIsChange(
+        parentsData.get(0),
+        r1.getCommit().getId(),
+        r1.getChangeId(),
+        r1.getChange().change().getChangeId(),
+        /* patchSetNumber= */ 1,
+        /* parentChangeStatus= */ "NEW",
+        /* isParentCommitMergedInTargetBranch= */ false);
+  }
+
+  @Test
+  public void parentData_parentIsALastPatchSet_parentIsPending() throws Exception {
+    // Create two patch-sets of change 1, and base change 2 on the second PS of change 1.
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r12 = amendChange(r1.getChangeId());
+    assertThat(gApi.changes().id(r1.getChangeId()).get().revisions).hasSize(2);
+    PushOneCommit.Result r2 = createChange();
+
+    List<ParentInfo> parentsData =
+        getRevisionWithParents(r2.getChangeId(), /* patchSetNumber= */ 1).parentsData;
+    assertThat(parentsData).hasSize(1);
+    assertParentIsChange(
+        parentsData.get(0),
+        r12.getCommit().getId(),
+        r1.getChangeId(),
+        r1.getChange().change().getChangeId(),
+        /* patchSetNumber= */ 2,
+        /* parentChangeStatus= */ "NEW",
+        /* isParentCommitMergedInTargetBranch= */ false);
+  }
+
+  @Test
+  public void parentData_parentIsANonLastPatchSet_parentIsMerged() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange();
+
+    // Create PS2 of the first change, and merge it.
+    testRepo.reset(r1.getCommit());
+    amendChange(r1.getChangeId());
+    approve(r1.getChangeId());
+    gApi.changes().id(r1.getChangeId()).current().submit();
+
+    List<ParentInfo> parentsData =
+        getRevisionWithParents(r2.getChangeId(), /* patchSetNumber= */ 1).parentsData;
+    assertThat(parentsData).hasSize(1);
+    assertParentIsChange(
+        parentsData.get(0),
+        r1.getCommit().getId(),
+        r1.getChangeId(),
+        r1.getChange().change().getChangeId(),
+        /* patchSetNumber= */ 1,
+        /* parentChangeStatus= */ "MERGED",
+        /* isParentCommitMergedInTargetBranch= */ false);
+  }
+
+  @Test
+  public void parentData_parentIsLastPatchSet_parentIsMerged_fastForwardSubmitStrategy()
+      throws Exception {
+    updateSubmitType(project, SubmitType.FAST_FORWARD_ONLY);
+
+    PushOneCommit.Result r11 = createChange();
+    PushOneCommit.Result r12 = amendChange(r11.getChangeId());
+    PushOneCommit.Result r2 = createChange();
+
+    // Merge the first change
+    approve(r11.getChangeId());
+    gApi.changes().id(r11.getChangeId()).current().submit();
+
+    List<ParentInfo> parentsData =
+        getRevisionWithParents(r2.getChangeId(), /* patchSetNumber= */ 1).parentsData;
+    assertThat(parentsData).hasSize(1);
+
+    // The second change is based on the last patch-set of the first change. But since the first
+    // change is merged and the submit strategy is fast-forward, PS2 of change 1 is now included
+    // in the target branch, hence the parent data reflects that.
+    assertParentIsInTargetBranch(
+        parentsData.get(0), "refs/heads/master", r12.getCommit().getId().name());
+    assertParentIsChange(
+        parentsData.get(0),
+        r12.getCommit().getId(),
+        r11.getChangeId(),
+        r11.getChange().change().getChangeId(),
+        /* patchSetNumber= */ 2,
+        /* parentChangeStatus= */ "MERGED",
+        /* isParentCommitMergedInTargetBranch= */ true);
+  }
+
+  @Test
+  public void parentData_parentIsLastPatchSet_parentIsMerged_rebaseAlwaysSubmitStrategy()
+      throws Exception {
+    updateSubmitType(project, SubmitType.REBASE_ALWAYS);
+
+    PushOneCommit.Result r11 = createChange();
+    PushOneCommit.Result r12 = amendChange(r11.getChangeId());
+    PushOneCommit.Result r2 = createChange();
+
+    // Merge the first change
+    approve(r11.getChangeId());
+    gApi.changes().id(r11.getChangeId()).current().submit();
+
+    List<ParentInfo> parentsData =
+        getRevisionWithParents(r2.getChangeId(), /* patchSetNumber= */ 1).parentsData;
+    assertThat(parentsData).hasSize(1);
+
+    // The second change is based on the last patch-set of the first change. But since the first
+    // change is merged and the submit strategy is rebase-always, PS2 of change 1 is not included
+    // in the target branch and there is another commit that got rebased in the target branch. The
+    // parent of change 2 is patch-set 2 of change 1.
+    assertParentIsChange(
+        parentsData.get(0),
+        r12.getCommit().getId(),
+        r11.getChangeId(),
+        r11.getChange().change().getChangeId(),
+        /* patchSetNumber= */ 2,
+        /* parentChangeStatus= */ "MERGED",
+        /* isParentCommitMergedInTargetBranch= */ false);
+  }
+
+  private RevisionInfo getRevisionWithParents(String changeId, int patchSetNumber)
+      throws Exception {
+    return getRevision(
+        changeId,
+        patchSetNumber,
+        EnumSet.complementOf(EnumSet.of(ListChangesOption.CHECK, ListChangesOption.SKIP_DIFFSTAT)));
+  }
+
+  private RevisionInfo getRevisionWithoutParents(String changeId, int patchSetNumber)
+      throws Exception {
+    return getRevision(
+        changeId,
+        patchSetNumber,
+        EnumSet.complementOf(
+            EnumSet.of(
+                ListChangesOption.PARENTS,
+                ListChangesOption.CHECK,
+                ListChangesOption.SKIP_DIFFSTAT)));
+  }
+
+  private RevisionInfo getRevision(
+      String changeId, int patchSetNumber, EnumSet<ListChangesOption> options) throws Exception {
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get(options);
+    return changeInfo.revisions.values().stream()
+        .filter(revision -> revision._number == patchSetNumber)
+        .findAny()
+        .get();
+  }
+
+  private void updateSubmitType(Project.NameKey project, SubmitType submitType) throws Exception {
+    ConfigInput input = new ConfigInput();
+    input.submitType = submitType;
+    gApi.projects().name(project.get()).config(input);
+  }
+
+  private void assertParentIsInTargetBranch(ParentInfo info, String branchName, String commitId) {
+    assertThat(info.branchName).isEqualTo(branchName);
+    assertThat(info.commitId).isEqualTo(commitId);
+    assertThat(info.isMergedInTargetBranch).isTrue();
+  }
+
+  private void assertParentIsChange(
+      ParentInfo info,
+      ObjectId parentCommitId,
+      String changeId,
+      Integer changeNumber,
+      Integer patchSetNumber,
+      String parentChangeStatus,
+      boolean isParentCommitMergedInTargetBranch) {
+    assertThat(info.commitId).isEqualTo(parentCommitId.name());
+    assertThat(info.changeStatus).isEqualTo(parentChangeStatus);
+    assertThat(info.changeNumber).isEqualTo(changeNumber);
+    assertThat(info.changeId).isEqualTo(changeId);
+    assertThat(info.patchSetNumber).isEqualTo(patchSetNumber);
+    assertThat(info.isMergedInTargetBranch).isEqualTo(isParentCommitMergedInTargetBranch);
+  }
+
   private static void assertCherryPickResult(
-      ChangeInfo changeInfo, CherryPickInput input, String srcChangeId) throws Exception {
+      ChangeInfo changeInfo, CherryPickInput input, String srcChangeId) {
     assertThat(changeInfo.changeId).isEqualTo(srcChangeId);
     assertThat(changeInfo.revisions.keySet()).containsExactly(changeInfo.currentRevision);
     RevisionInfo revisionInfo = changeInfo.revisions.get(changeInfo.currentRevision);
@@ -2129,6 +2561,10 @@
     assertThat(revisionInfo.commit.parents.get(0).commit).isEqualTo(input.base);
   }
 
+  private void move(String changeId, String destination) throws Exception {
+    gApi.changes().id(changeId).move(destination);
+  }
+
   private PushOneCommit.Result updateChange(PushOneCommit.Result r, String content)
       throws Exception {
     PushOneCommit push =
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
index 1363ce7..b31d35c 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -34,7 +34,10 @@
 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.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+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.Patch;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
@@ -73,6 +76,8 @@
 public class RobotCommentsIT extends AbstractDaemonTest {
   @Inject private TestCommentHelper testCommentHelper;
   @Inject private ChangeOperations changeOperations;
+  @Inject private AccountOperations accountOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   private static final String PLAIN_TEXT_CONTENT_TYPE = "text/plain";
   private static final String GERRIT_COMMIT_MESSAGE_TYPE = "text/x-gerrit-commit-message";
@@ -755,6 +760,46 @@
   }
 
   @Test
+  public void applyStoredFixAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Create change
+    Change.Id change =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file(FILE_NAME)
+            .content(FILE_CONTENT)
+            .owner(testUser)
+            .create();
+
+    // Add Robot Comment to the change
+    fixReplacementInfo.path = FILE_NAME;
+    fixReplacementInfo.replacement = "Modified content";
+    fixReplacementInfo.range = createRange(3, 1, 3, 3);
+    testCommentHelper.addRobotComment(project + "~" + change.get(), withFixRobotCommentInput);
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Fetch Fix ID
+    List<RobotCommentInfo> robotCommentInfoList =
+        gApi.changes().id(change.get()).current().robotCommentsAsList();
+
+    List<String> fixIds = getFixIds(robotCommentInfoList);
+    String fixId = Iterables.getOnlyElement(fixIds);
+
+    // Apply fix
+    gApi.changes().id(change.get()).current().applyFix(fixId);
+
+    EditInfo editInfo = gApi.changes().id(change.get()).edit().get().orElseThrow();
+    assertThat(editInfo.commit.committer.email).isEqualTo(emailOne);
+  }
+
+  @Test
   public void storedFixSpanningMultipleLinesCanBeApplied() throws Exception {
     fixReplacementInfo.path = FILE_NAME;
     fixReplacementInfo.replacement = "Modified content\n5";
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 4168164..9c691ae 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -37,9 +37,13 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.UseClockStep;
+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.RawInputUtil;
+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.Patch;
@@ -112,6 +116,8 @@
 
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private AccountOperations accountOperations;
+  @Inject private ChangeOperations changeOperations;
 
   private String changeId;
   private String changeId2;
@@ -265,6 +271,30 @@
   }
 
   @Test
+  public void rebaseEditAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Create change to edit
+    Change.Id change = changeOperations.newChange().project(project).owner(testUser).create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Create edit
+    createEmptyEditFor(project + "~" + change.get());
+    // Add new patch-set to change
+    changeOperations.change(change).newPatchset().create();
+    // Rebase Edit
+    gApi.changes().id(change.get()).edit().rebase();
+
+    EditInfo editInfo = gApi.changes().id(change.get()).edit().get().orElseThrow();
+    assertThat(editInfo.commit.committer.email).isEqualTo(emailOne);
+  }
+
+  @Test
   public void rebaseEditRest() throws Exception {
     PatchSet previousPatchSet = getCurrentPatchSet(changeId2);
     createEmptyEditFor(changeId2);
@@ -312,6 +342,33 @@
   }
 
   @Test
+  public void updateExistingFileAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Create change to edit
+    Change.Id change =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file(FILE_NAME)
+            .content("content")
+            .owner(testUser)
+            .create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Modify file
+    gApi.changes().id(change.get()).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+
+    EditInfo editInfo = gApi.changes().id(change.get()).edit().get().orElseThrow();
+    assertThat(editInfo.commit.committer.email).isEqualTo(emailOne);
+  }
+
+  @Test
   public void updateCommitMessageByEditingMagicCommitMsgFile() throws Exception {
     createEmptyEditFor(changeId);
     String updatedCommitMsg = "Foo Bar\n\nChange-Id: " + changeId + "\n";
@@ -455,6 +512,28 @@
   }
 
   @Test
+  public void updateMessageAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Create change to edit
+    Change.Id change = changeOperations.newChange().project(project).owner(testUser).create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Update commit message
+    ChangeInfo changeInfo = gApi.changes().id(change.get()).get();
+    String msg = String.format("New commit message\n\nChange-Id: %s\n", changeInfo.changeId);
+    gApi.changes().id(change.get()).edit().modifyCommitMessage(msg);
+
+    EditInfo editInfo = gApi.changes().id(change.get()).edit().get().orElseThrow();
+    assertThat(editInfo.commit.committer.email).isEqualTo(emailOne);
+  }
+
+  @Test
   public void updateMessageRest() throws Exception {
     adminRestSession.get(urlEditMessage(changeId, false)).assertNotFound();
     EditMessage.Input in = new EditMessage.Input();
@@ -601,6 +680,33 @@
   }
 
   @Test
+  public void deleteExistingFileAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Create change to edit
+    Change.Id change =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file(FILE_NAME)
+            .content("content")
+            .owner(testUser)
+            .create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Delete file
+    gApi.changes().id(change.get()).edit().deleteFile(FILE_NAME);
+
+    EditInfo editInfo = gApi.changes().id(change.get()).edit().get().orElseThrow();
+    assertThat(editInfo.commit.committer.email).isEqualTo(emailOne);
+  }
+
+  @Test
   public void renameExistingFile() throws Exception {
     createEmptyEditFor(changeId);
     gApi.changes().id(changeId).edit().renameFile(FILE_NAME, FILE_NAME3);
@@ -609,6 +715,33 @@
   }
 
   @Test
+  public void renameExistingFileAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Create change to edit
+    Change.Id change =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file(FILE_NAME)
+            .content("content")
+            .owner(testUser)
+            .create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Rename file
+    gApi.changes().id(change.get()).edit().renameFile(FILE_NAME, FILE_NAME3);
+
+    EditInfo editInfo = gApi.changes().id(change.get()).edit().get().orElseThrow();
+    assertThat(editInfo.commit.committer.email).isEqualTo(emailOne);
+  }
+
+  @Test
   public void renameExistingFileToInvalidPath() throws Exception {
     createEmptyEditFor(changeId);
     BadRequestException badRequest =
@@ -644,6 +777,33 @@
   }
 
   @Test
+  public void restoreFileAfterUpdatingPreferredEmail() throws Exception {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Create change to edit
+    Change.Id change =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file(FILE_NAME)
+            .content("content")
+            .owner(testUser)
+            .create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Restore file to the state in the parent of change
+    gApi.changes().id(change.get()).edit().restoreFile(FILE_NAME);
+
+    EditInfo editInfo = gApi.changes().id(change.get()).edit().get().orElseThrow();
+    assertThat(editInfo.commit.committer.email).isEqualTo(emailOne);
+  }
+
+  @Test
   public void revertChanges() throws Exception {
     createEmptyEditFor(changeId2);
     gApi.changes().id(changeId2).edit().restoreFile(FILE_NAME);
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java b/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java
index a7e673a..d3e70b2 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractGitOverHttpServlet.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance.git;
 
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
@@ -43,7 +44,9 @@
 
   @Before
   public void beforeEach() throws Exception {
-    jettyServer = server.getHttpdInjector().getInstance(JettyServer.class);
+    checkState(server.getHttpdInjector().isPresent(), "GerritServer must have HttpdInjector");
+    jettyServer = server.getHttpdInjector().get().getInstance(JettyServer.class);
+
     CredentialsProvider.setDefault(
         new UsernamePasswordCredentialsProvider(admin.username(), admin.httpPassword()));
     selectProtocol(AbstractPushForReview.Protocol.HTTP);
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index e120f97..2ab054b 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -535,6 +535,16 @@
   }
 
   @Test
+  @GerritConfig(name = "change.topicLimit", value = "3")
+  public void pushForMasterWithTopicExceedsSizeLimitFails() throws Exception {
+    pushTo("refs/for/master%topic=limited").assertOkStatus();
+    pushTo("refs/for/master%topic=limited").assertOkStatus();
+    pushTo("refs/for/master%topic=limited").assertOkStatus();
+    PushOneCommit.Result r = pushTo("refs/for/master%topic=limited");
+    r.assertErrorStatus("topicLimit");
+  }
+
+  @Test
   public void pushForMasterWithNotify() throws Exception {
     // create a user that watches the project
     TestAccount user3 = accountCreator.create("user3", "user3@example.com", "User3", null);
@@ -1793,6 +1803,29 @@
   }
 
   @Test
+  @GerritConfig(name = "receive.enableChangeIdLinkFooters", value = "false")
+  public void pushWithLinkFooter_linkFootersDisabled() throws Exception {
+    String changeId = "I0123456789abcdef0123456789abcdef01234567";
+    String url = cfg.getString("gerrit", null, "canonicalWebUrl");
+    if (!url.endsWith("/")) {
+      url += "/";
+    }
+    createCommit(testRepo, "test commit\n\nLink: " + url + "id/" + changeId);
+    pushForReviewRejected(testRepo, "missing Change-Id in message footer");
+  }
+
+  @Test
+  @GerritConfig(name = "receive.enableChangeIdLinkFooters", value = "false")
+  public void pushWithChangeIdFooter_linkFootersDisabled() throws Exception {
+    PushOneCommit.Result r = pushTo("refs/for/master");
+    r.assertOkStatus();
+    r.assertChange(Change.Status.NEW, null);
+
+    List<ChangeMessageInfo> messages = getMessages(r.getChangeId());
+    assertThat(messages.get(0).message).isEqualTo("Uploaded patch set 1.");
+  }
+
+  @Test
   public void pushWithWrongHostLinkFooter() throws Exception {
     String changeId = "I0123456789abcdef0123456789abcdef01234567";
     createCommit(testRepo, "test commit\n\nLink: https://wronghost/id/" + changeId);
diff --git a/javatests/com/google/gerrit/acceptance/git/DirectPushRefUpdateContextIT.java b/javatests/com/google/gerrit/acceptance/git/DirectPushRefUpdateContextIT.java
new file mode 100644
index 0000000..e802604
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/git/DirectPushRefUpdateContextIT.java
@@ -0,0 +1,73 @@
+// 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.acceptance.git;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType;
+import com.google.gerrit.testing.RefUpdateContextCollector;
+import java.util.Map.Entry;
+import org.junit.Rule;
+import org.junit.Test;
+
+public class DirectPushRefUpdateContextIT extends AbstractDaemonTest {
+  @Rule
+  public RefUpdateContextCollector refUpdateContextCollector = new RefUpdateContextCollector();
+
+  @Test
+  public void directPushWithoutJustification_emptyJustification() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
+
+    PushOneCommit.Result result = push.to("refs/heads/master");
+
+    result.assertOkStatus();
+    ImmutableList<Entry<String, ImmutableList<RefUpdateContext>>> updates =
+        refUpdateContextCollector.getContextsByUpdateType(RefUpdateType.DIRECT_PUSH);
+    assertThat(updates).hasSize(1);
+    Entry<String, ImmutableList<RefUpdateContext>> entry = updates.get(0);
+    assertThat(entry.getKey()).isEqualTo("refs/heads/master");
+    ImmutableList<RefUpdateContext> ctxts = entry.getValue();
+    assertThat(ctxts).hasSize(1);
+    RefUpdateContext ctx = ctxts.get(0);
+    assertThat(ctx.getUpdateType()).isEqualTo(RefUpdateType.DIRECT_PUSH);
+    assertThat(ctx.getJustification()).isEmpty();
+  }
+
+  @Test
+  public void directPushWithJustification_justificationStoredInContext() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
+    push.setPushOptions(ImmutableList.of("push-justification=test justification"));
+    PushOneCommit.Result result = push.to("refs/heads/master");
+
+    result.assertOkStatus();
+    ImmutableList<Entry<String, ImmutableList<RefUpdateContext>>> updates =
+        refUpdateContextCollector.getContextsByUpdateType(RefUpdateType.DIRECT_PUSH);
+    assertThat(updates).hasSize(1);
+    Entry<String, ImmutableList<RefUpdateContext>> entry = updates.get(0);
+    assertThat(entry.getKey()).isEqualTo("refs/heads/master");
+    ImmutableList<RefUpdateContext> ctxts = entry.getValue();
+    assertThat(ctxts).hasSize(1);
+    RefUpdateContext ctx = ctxts.get(0);
+    assertThat(ctx.getUpdateType()).isEqualTo(RefUpdateType.DIRECT_PUSH);
+    assertThat(ctx.getJustification()).hasValue("test justification");
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java b/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java
index 27962da..d48f41d 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java
@@ -40,11 +40,11 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountProperties;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.ProjectWatches;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 9e85d8c..3bec694 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -49,11 +49,11 @@
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.Sequence;
 import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.receive.ReceiveCommitsAdvertiseRefsHookChain;
 import com.google.gerrit.server.git.receive.testing.TestRefAdvertiser;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -875,6 +875,47 @@
         "refs/tags/new-tag");
   }
 
+  // rcMaster (c1 master master-tag) <- rcBranch (c2 branch branch-tag) <- rcBranch (c2 branch) <-
+  // newcommit1 <- newcommit2 (new-branch)
+  @Test
+  public void uploadPackReachableTagVisibleFromLeafBranch() throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      // rcBranch (c2 branch) <- newcommit1 (new-branch)
+      PushOneCommit.Result r =
+          pushFactory
+              .create(admin.newIdent(), testRepo)
+              .setParent(rcBranch)
+              .to("refs/heads/new-branch");
+      r.assertOkStatus();
+      RevCommit branchRc = r.getCommit();
+
+      // rcBranch (c2) <- newcommit1 <- newcommit2 (new-branch)
+      r =
+          pushFactory
+              .create(admin.newIdent(), testRepo)
+              .setParent(branchRc)
+              .to("refs/heads/new-branch");
+      r.assertOkStatus();
+    }
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(deny(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+        .add(deny(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/new-branch").group(REGISTERED_USERS))
+        .update();
+
+    requestScopeOperations.setApiUser(user.id());
+    assertUploadPackRefs(
+        "refs/heads/new-branch",
+        // 'master' and 'branch' branches are not visible but 'master-tag' and 'branch-tag' are
+        // reachable from new-branch (since PushOneCommit always bases changes on each other).
+        "refs/tags/branch-tag",
+        "refs/tags/master-tag");
+    // tree-tag not visible. See comment in subsetOfBranchesVisibleIncludingHead.
+  }
+
   // first  ls-remote: rcBranch (c2 branch)               <- newcommit1 (updated-tag)
   // second ls-remote: rcBranch (c2 branch updated-tag)
   @Test
@@ -1372,8 +1413,8 @@
             RefNames.REFS_GROUPNAMES,
             RefNames.refsGroups(admins),
             RefNames.refsGroups(nonInteractiveUsers),
-            RefNames.REFS_SEQUENCES + Sequences.NAME_ACCOUNTS,
-            RefNames.REFS_SEQUENCES + Sequences.NAME_GROUPS,
+            RefNames.REFS_SEQUENCES + Sequence.NAME_ACCOUNTS,
+            RefNames.REFS_SEQUENCES + Sequence.NAME_GROUPS,
             RefNames.REFS_CONFIG,
             Constants.HEAD);
 
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
index 09957b3..c9607f5 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.server.project.ProjectConfig.RULES_PL_FILE;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -26,11 +25,11 @@
 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;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
-import com.google.gerrit.server.project.SubmitRuleEvaluator;
-import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
@@ -57,7 +56,7 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private IndexOperations.Change changeIndexOperations;
   @Inject private IndexOperations.Account accountIndexOperations;
-  @Inject private SubmitRuleEvaluator.Factory evaluatorFactory;
+  @Inject SubmitRequirementsEvaluator submitRequirementsEvaluator;
 
   @Test
   @GerritConfig(name = "submodule.enableSuperProjectSubscriptions", value = "false")
@@ -651,7 +650,7 @@
     cherryPickInput.allowConflicts = true;
 
     // The rule will fail if the next change has a submodule file modification with subKey.
-    modifySubmitRulesToBlockSubmoduleChanges(String.format("file('%s','M','SUBMODULE')", subKey));
+    addBlockingSubmodulesSubmitRequirement();
 
     // Cherry-pick the newly created commit which contains a submodule update, to branch "branch".
     ChangeApi changeApi =
@@ -660,8 +659,9 @@
     // Add another file to this change for good measure.
     PushOneCommit.Result result =
         amendChange(changeApi.get().changeId, "subject", "newFile", "content");
+    approve(result.getChangeId());
 
-    assertThat(getStatus(result.getChange())).isEqualTo("NOT_READY");
+    assertThat(getStatus(result.getChange())).isFalse();
     assertThat(gApi.changes().id(result.getChangeId()).get().submittable).isFalse();
   }
 
@@ -673,7 +673,7 @@
     cherryPickInput.allowConflicts = true;
 
     // The rule will fail if the next change has any submodule file.
-    modifySubmitRulesToBlockSubmoduleChanges("file(_,_,'SUBMODULE')");
+    addBlockingSubmodulesSubmitRequirement();
 
     // Cherry-pick the newly created commit which contains a submodule update, to branch "branch".
     ChangeApi changeApi =
@@ -682,19 +682,21 @@
     // Add another file to this change for good measure.
     PushOneCommit.Result result =
         amendChange(changeApi.get().changeId, "subject", "newFile", "content");
+    approve(result.getChangeId());
 
-    assertThat(getStatus(result.getChange())).isEqualTo("NOT_READY");
+    assertThat(getStatus(result.getChange())).isFalse();
     assertThat(gApi.changes().id(result.getChangeId()).get().submittable).isFalse();
   }
 
   @Test
   public void doNotBlockSubmissionWithoutSubmodules() throws Exception {
-    modifySubmitRulesToBlockSubmoduleChanges("file(_,_,'SUBMODULE')");
+    addBlockingSubmodulesSubmitRequirement();
 
     PushOneCommit.Result result =
         createChange(superRepo, "refs/heads/master", "subject", "newFile", "content", null);
+    approve(result.getChangeId());
 
-    assertThat(getStatus(result.getChange())).isEqualTo("OK");
+    assertThat(getStatus(result.getChange())).isTrue();
     assertThat(gApi.changes().id(result.getChangeId()).get().submittable).isTrue();
   }
 
@@ -725,36 +727,22 @@
         .getObjectId();
   }
 
-  private void modifySubmitRulesToBlockSubmoduleChanges(String filePrologQuery) throws Exception {
-    String newContent =
-        String.format(
-            "submit_rule(submit(R)) :-\n"
-                + "  gerrit:includes_file(%s),\n"
-                + "  !,\n"
-                + "  R = label('All-Submodules-Resolved', need(_)).\n"
-                + "submit_rule(submit(label('All-Submodules-Resolved', ok(A)))) :-\n"
-                + "  gerrit:commit_author(A).",
-            filePrologQuery);
-
-    try (Repository repo = repoManager.openRepository(superKey);
-        TestRepository<Repository> testRepo = new TestRepository<>(repo)) {
-      testRepo
-          .branch(RefNames.REFS_CONFIG)
-          .commit()
-          .author(admin.newIdent())
-          .committer(admin.newIdent())
-          .add(RULES_PL_FILE, newContent)
-          .message("Modify rules.pl")
-          .create();
-    }
-    projectCache.evict(superKey);
+  private void addBlockingSubmodulesSubmitRequirement() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "Block-Submodule-Change";
+    input.submittabilityExpression = "-has:submodule-update";
+    gApi.projects()
+        .name(allProjects.get())
+        .submitRequirement("Block-Submodule-Change")
+        .create(input)
+        .get();
   }
 
-  private String getStatus(ChangeData cd) throws Exception {
+  private boolean getStatus(ChangeData cd) throws Exception {
     try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites();
         AutoCloseable accountIndex = accountIndexOperations.disableReadsAndWrites()) {
-      SubmitRuleEvaluator ruleEvaluator = evaluatorFactory.create(SubmitRuleOptions.defaults());
-      return ruleEvaluator.evaluate(cd).iterator().next().status.toString();
+      return submitRequirementsEvaluator.evaluateAllRequirements(cd).values().stream()
+          .allMatch(SubmitRequirementResult::fulfilled);
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ChangeExternalIdCaseSensitivityIT.java b/javatests/com/google/gerrit/acceptance/pgm/ChangeExternalIdCaseSensitivityIT.java
index 83df896..05cf12c 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/ChangeExternalIdCaseSensitivityIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/ChangeExternalIdCaseSensitivityIT.java
@@ -30,7 +30,7 @@
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
-import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNotes;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
diff --git a/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java b/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java
index c868d0b..2cc4857 100644
--- a/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.httpd.restapi.RestApiServlet.SC_CLIENT_CLOSED_REQUEST;
+import static org.apache.http.HttpStatus.SC_INTERNAL_SERVER_ERROR;
 import static org.apache.http.HttpStatus.SC_REQUEST_TIMEOUT;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -100,7 +101,7 @@
     try (Registration registration =
         extensionRegistry.newRegistration().add(projectCreationListener)) {
       RestResponse response = adminRestSession.put("/projects/" + name("new"));
-      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
       assertThat(response.getEntityContent()).isEqualTo("Server Deadline Exceeded");
     }
   }
@@ -119,7 +120,7 @@
     try (Registration registration =
         extensionRegistry.newRegistration().add(projectCreationListener)) {
       RestResponse response = adminRestSession.put("/projects/" + name("new"));
-      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
       assertThat(response.getEntityContent())
           .isEqualTo("Server Deadline Exceeded\n\ndeadline = 10m");
     }
@@ -140,7 +141,7 @@
     try (Registration registration =
         extensionRegistry.newRegistration().add(projectCreationListener)) {
       RestResponse response = adminRestSession.put("/projects/" + name("new"));
-      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
       assertThat(response.getEntityContent())
           .isEqualTo("Server Deadline Exceeded\n\ndeadline = 10m");
     }
@@ -197,7 +198,7 @@
   public void abortIfServerDeadlineExceeded() throws Exception {
     testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
-    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
     assertThat(response.getEntityContent()).isEqualTo("Server Deadline Exceeded\n\ntimeout=1ms");
   }
 
@@ -207,7 +208,7 @@
   public void stricterDeadlineTakesPrecedence() throws Exception {
     testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
-    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
     assertThat(response.getEntityContent())
         .isEqualTo("Server Deadline Exceeded\n\nfoo.timeout=1ms");
   }
@@ -218,7 +219,7 @@
   public void abortIfServerDeadlineExceeded_requestType() throws Exception {
     testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
-    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
     assertThat(response.getEntityContent())
         .isEqualTo("Server Deadline Exceeded\n\ndefault.timeout=1ms");
   }
@@ -229,7 +230,7 @@
   public void abortIfServerDeadlineExceeded_requestUriPattern() throws Exception {
     testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
-    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
     assertThat(response.getEntityContent())
         .isEqualTo("Server Deadline Exceeded\n\ndefault.timeout=1ms");
   }
@@ -242,7 +243,7 @@
   public void abortIfServerDeadlineExceeded_excludedRequestUriPattern() throws Exception {
     testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
-    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
     assertThat(response.getEntityContent())
         .isEqualTo("Server Deadline Exceeded\n\ndefault.timeout=1ms");
   }
@@ -257,7 +258,7 @@
       throws Exception {
     testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
-    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
     assertThat(response.getEntityContent())
         .isEqualTo("Server Deadline Exceeded\n\ndefault.timeout=1ms");
   }
@@ -268,7 +269,7 @@
   public void abortIfServerDeadlineExceeded_projectPattern() throws Exception {
     testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
-    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
     assertThat(response.getEntityContent())
         .isEqualTo("Server Deadline Exceeded\n\ndefault.timeout=1ms");
   }
@@ -279,7 +280,7 @@
   public void abortIfServerDeadlineExceeded_account() throws Exception {
     testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
-    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
     assertThat(response.getEntityContent())
         .isEqualTo("Server Deadline Exceeded\n\ndefault.timeout=1ms");
   }
@@ -356,7 +357,7 @@
   public void nonAdvisoryDeadlineIsAppliedIfStricterAdvisoryDeadlineExists() throws Exception {
     testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(4));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
-    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
     assertThat(response.getEntityContent())
         .isEqualTo("Server Deadline Exceeded\n\ndefault.timeout=2ms");
   }
@@ -462,7 +463,7 @@
     try (Registration registration =
         extensionRegistry.newRegistration().add(projectCreationValidationListener)) {
       RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
-      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
       assertThat(response.getEntityContent())
           .isEqualTo("Server Deadline Exceeded\n\ndefault.timeout=500ms");
     }
diff --git a/javatests/com/google/gerrit/acceptance/rest/DynamicOptionsBeanParseListenerIT.java b/javatests/com/google/gerrit/acceptance/rest/DynamicOptionsBeanParseListenerIT.java
index 39f1e8d..f199b55 100644
--- a/javatests/com/google/gerrit/acceptance/rest/DynamicOptionsBeanParseListenerIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/DynamicOptionsBeanParseListenerIT.java
@@ -21,7 +21,7 @@
 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.gerrit.server.restapi.project.ListProjectsImpl;
 import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.AbstractModule;
@@ -48,7 +48,7 @@
   protected static class ListProjectsBeanListener implements DynamicOptions.BeanParseListener {
     @Override
     public void onBeanParseStart(String plugin, Object bean) {
-      ListProjects listProjects = (ListProjects) bean;
+      ListProjectsImpl listProjects = (ListProjectsImpl) bean;
       listProjects.setLimit(1);
     }
 
@@ -60,7 +60,7 @@
     @Override
     public void configure() {
       bind(DynamicOptions.DynamicBean.class)
-          .annotatedWith(Exports.named(ListProjects.class))
+          .annotatedWith(Exports.named(ListProjectsImpl.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 7d6f6c8..c5e6507 100644
--- a/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.httpd.restapi.ParameterParser;
 import com.google.gerrit.httpd.restapi.RestApiServlet;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.List;
@@ -427,6 +428,70 @@
     adminRestSession.get("/projects").assertOK();
   }
 
+  @Test
+  public void testNumericChangeIdRedirectWithPrefix() throws Exception {
+    int changeNumber = createChange().getChange().getId().get();
+
+    String redirectUri = String.format("/c/%s/+/%d/", project.get(), changeNumber);
+    anonymousRestSession.get("/c/" + changeNumber).assertTemporaryRedirect(redirectUri);
+  }
+
+  @Test
+  public void testCommentLinkWithPrefixRedirects() throws Exception {
+    int changeNumber = createChange().getChange().getId().get();
+    String commentId = "ff3303fd_8341647b";
+
+    String redirectUri =
+        String.format("/c/%s/+/%d/comment/%s", project.get(), changeNumber, commentId);
+
+    anonymousRestSession
+        .get(String.format("/c/%s/comment/%s", changeNumber, commentId))
+        .assertTemporaryRedirect(redirectUri);
+  }
+
+  @Test
+  public void testNumericChangeIdWithExtraSegments() throws Exception {
+    ChangeData changeData = createChange().getChange();
+    int changeNumber = changeData.getId().get();
+
+    assertChangeNumberWithSuffixRedirected(changeNumber, "1..2");
+    assertChangeNumberWithSuffixRedirected(changeNumber, "2");
+    assertChangeNumberWithSuffixRedirected(changeNumber, "2/COMMIT_MSG");
+    assertChangeNumberWithSuffixRedirected(changeNumber, "2?foo=bar");
+    assertChangeNumberWithSuffixRedirected(changeNumber, "2/path/to/source/file/MyClass.java");
+  }
+
+  private void assertChangeNumberWithSuffixRedirected(int changeNumber, String suffix)
+      throws Exception {
+    String redirectUri =
+        anonymousRestSession.getUrl(
+            String.format("/c/%s/+/%d/%s", project.get(), changeNumber, suffix));
+    anonymousRestSession
+        .get(String.format("/c/%d/%s", changeNumber, suffix))
+        .assertTemporaryRedirectUri(redirectUri);
+  }
+
+  @Test
+  public void testCommentLinkWithoutPrefixRedirects() throws Exception {
+    int changeNumber = createChange().getChange().getId().get();
+    String commentId = "ff3303fd_8341647b";
+
+    String redirectPath =
+        String.format("/c/%s/+/%d/comment/%s", project.get(), changeNumber, commentId);
+
+    anonymousRestSession
+        .get(String.format("/%s/comment/%s", changeNumber, commentId))
+        .assertTemporaryRedirect(redirectPath);
+  }
+
+  @Test
+  public void testNumericChangeIdRedirectWithoutPrefix() throws Exception {
+    int changeNumber = createChange().getChange().getId().get();
+
+    String redirectUri = String.format("/c/%s/+/%d/", project.get(), changeNumber);
+    anonymousRestSession.get("/" + changeNumber).assertTemporaryRedirect(redirectUri);
+  }
+
   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 9710bf4..2d73e97 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -18,9 +18,9 @@
 import static org.apache.http.HttpStatus.SC_CREATED;
 import static org.apache.http.HttpStatus.SC_INTERNAL_SERVER_ERROR;
 import static org.apache.http.HttpStatus.SC_OK;
+import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.timeout;
 import static org.mockito.Mockito.verify;
@@ -38,12 +38,16 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 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.change.ReviewerSuggestion;
+import com.google.gerrit.server.change.SuggestedReviewer;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.validators.CommitValidationException;
@@ -801,6 +805,112 @@
   }
 
   @Test
+  @GerritConfig(name = "tracing.issue123.requestQueryStringPattern", value = ".*limit=.*")
+  public void traceRequestQueryString() throws Exception {
+    String changeId = createChange().getChangeId();
+    TraceReviewerSuggestion reviewerSuggestion = new TraceReviewerSuggestion();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(reviewerSuggestion, /* exportName= */ "foo")) {
+      RestResponse response =
+          adminRestSession.get(String.format("/changes/%s/suggest_reviewers?limit=10", changeId));
+      assertThat(response.getStatusCode()).isEqualTo(SC_OK);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(reviewerSuggestion.traceId).isEqualTo("issue123");
+      assertThat(reviewerSuggestion.isLoggingForced).isTrue();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.requestQueryStringPattern", value = ".*query=.*")
+  public void traceRequestQueryStringNoMatch() throws Exception {
+    String changeId = createChange().getChangeId();
+    TraceReviewerSuggestion reviewerSuggestion = new TraceReviewerSuggestion();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(reviewerSuggestion, /* exportName= */ "foo")) {
+      RestResponse response =
+          adminRestSession.get(String.format("/changes/%s/suggest_reviewers?limit=10", changeId));
+      assertThat(response.getStatusCode()).isEqualTo(SC_OK);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(reviewerSuggestion.traceId).isNull();
+      assertThat(reviewerSuggestion.isLoggingForced).isFalse();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.requestQueryStringPattern", value = "][")
+  public void traceRequestQueryStringInvalidRegEx() throws Exception {
+    String changeId = createChange().getChangeId();
+    TraceReviewerSuggestion reviewerSuggestion = new TraceReviewerSuggestion();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(reviewerSuggestion, /* exportName= */ "foo")) {
+      RestResponse response =
+          adminRestSession.get(String.format("/changes/%s/suggest_reviewers?limit=10", changeId));
+      assertThat(response.getStatusCode()).isEqualTo(SC_OK);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(reviewerSuggestion.traceId).isNull();
+      assertThat(reviewerSuggestion.isLoggingForced).isFalse();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.headerPattern", value = "User-Agent=foo.*")
+  public void traceHeader() throws Exception {
+    String changeId = createChange().getChangeId();
+    TraceReviewerSuggestion reviewerSuggestion = new TraceReviewerSuggestion();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(reviewerSuggestion, /* exportName= */ "foo")) {
+
+      RestResponse response =
+          adminRestSession.getWithHeaders(
+              String.format("/changes/%s/suggest_reviewers?limit=10", changeId),
+              new BasicHeader("User-Agent", "foo-bar"),
+              new BasicHeader("Other-Header", "baz"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_OK);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(reviewerSuggestion.traceId).isEqualTo("issue123");
+      assertThat(reviewerSuggestion.isLoggingForced).isTrue();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.headerPattern", value = "User-Agent=bar.*")
+  public void traceHeaderNoMatch() throws Exception {
+    String changeId = createChange().getChangeId();
+    TraceReviewerSuggestion reviewerSuggestion = new TraceReviewerSuggestion();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(reviewerSuggestion, /* exportName= */ "foo")) {
+      RestResponse response =
+          adminRestSession.getWithHeaders(
+              String.format("/changes/%s/suggest_reviewers?limit=10", changeId),
+              new BasicHeader("User-Agent", "foo-bar"),
+              new BasicHeader("Other-Header", "baz"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_OK);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(reviewerSuggestion.traceId).isNull();
+      assertThat(reviewerSuggestion.isLoggingForced).isFalse();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.headerPattern", value = "][")
+  public void traceHeaderInvalidRegEx() throws Exception {
+    String changeId = createChange().getChangeId();
+    TraceReviewerSuggestion reviewerSuggestion = new TraceReviewerSuggestion();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(reviewerSuggestion, /* exportName= */ "foo")) {
+      RestResponse response =
+          adminRestSession.getWithHeaders(
+              String.format("/changes/%s/suggest_reviewers?limit=10", changeId),
+              new BasicHeader("User-Agent", "foo-bar"),
+              new BasicHeader("Other-Header", "baz"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_OK);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(reviewerSuggestion.traceId).isNull();
+      assertThat(reviewerSuggestion.isLoggingForced).isFalse();
+    }
+  }
+
+  @Test
   @GerritConfig(name = "retry.retryWithTraceOnFailure", value = "true")
   public void autoRetryWithTrace() throws Exception {
     String changeId = createChange().getChangeId();
@@ -898,6 +1008,23 @@
     }
   }
 
+  private static class TraceReviewerSuggestion implements ReviewerSuggestion {
+    String traceId;
+    Boolean isLoggingForced;
+
+    @Override
+    public Set<SuggestedReviewer> suggestReviewers(
+        Project.NameKey project,
+        Change.Id changeId,
+        String query,
+        Set<com.google.gerrit.entities.Account.Id> candidates) {
+      this.traceId =
+          Iterables.getFirst(LoggingContext.getInstance().getTagsAsMap().get("TRACE_ID"), null);
+      this.isLoggingForced = LoggingContext.getInstance().shouldForceLogging(null, null, false);
+      return ImmutableSet.of();
+    }
+  }
+
   private static class TraceChangeIndexedListener implements ChangeIndexedListener {
     ImmutableSetMultimap<String, String> tags;
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index 61164f7..749ca79 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -58,11 +59,12 @@
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
-import com.google.gerrit.server.account.externalids.ExternalIdNotes;
-import com.google.gerrit.server.account.externalids.ExternalIdReader;
 import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdFactoryNoteDbImpl;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNotes;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdReader;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gson.reflect.TypeToken;
@@ -77,10 +79,12 @@
 import java.util.Locale;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.api.errors.TransportException;
 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.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushResult;
@@ -101,7 +105,8 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExternalIdKeyFactory externalIdKeyFactory;
-  @Inject private ExternalIdFactory externalIdFactory;
+  @Inject private ExternalIdFactoryNoteDbImpl externalIdFactory;
+  @Inject private AllUsersName allUsersName;
 
   @Test
   public void getExternalIds() throws Exception {
@@ -778,6 +783,42 @@
   }
 
   @Test
+  @UseClockStep
+  public void getLastUpdated() throws Exception {
+    Account.Id accountId = Account.id(1);
+
+    // insert one external ID
+    ExternalId extId1 = externalIdFactory.create("foo", "bar", accountId);
+    accountsUpdateProvider
+        .get()
+        .insert("Create Test Account", accountId, u -> u.addExternalId(extId1));
+    ObjectId rev = readRevision();
+    long extId1ExpectedLastUpdated = getCommitTimeUs(rev);
+    assertThat(externalIds.allByAccount().asMap().get(accountId)).containsExactly(extId1);
+
+    // insert another external ID
+    ExternalId extId2 = externalIdFactory.create("foo", "baz", accountId);
+    accountsUpdateProvider.get().update("Add External ID", accountId, u -> u.addExternalId(extId2));
+    rev = readRevision();
+    long extId2ExpectedLastUpdated = getCommitTimeUs(rev);
+    assertThat(extId1ExpectedLastUpdated).isLessThan(extId2ExpectedLastUpdated);
+    assertThat(externalIds.allByAccount().asMap().get(accountId)).containsExactly(extId1, extId2);
+
+    // update the first external ID
+    ExternalId updatedExtId1 =
+        externalIdFactory.create(
+            extId1.key(), accountId, "foo.bar@example.com", /* hashedPassword= */ null);
+    accountsUpdateProvider
+        .get()
+        .update("Update External ID", accountId, u -> u.updateExternalId(updatedExtId1));
+    rev = readRevision();
+    extId1ExpectedLastUpdated = getCommitTimeUs(rev);
+    assertThat(extId1ExpectedLastUpdated).isGreaterThan(extId2ExpectedLastUpdated);
+    assertThat(externalIds.allByAccount().asMap().get(accountId))
+        .containsExactly(updatedExtId1, extId2);
+  }
+
+  @Test
   @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
   public void createCaseInsensitiveExternalId_DuplicateKey() throws Exception {
     try (Repository allUsersRepo = repoManager.openRepository(allUsers);
@@ -1042,9 +1083,9 @@
     info.emailAddress = extId.email();
     info.canDelete = !extId.isScheme(SCHEME_USERNAME) ? true : null;
     info.trusted =
-        extId.isScheme(SCHEME_MAILTO)
+        (extId.isScheme(SCHEME_MAILTO)
                 || extId.isScheme(SCHEME_UUID)
-                || extId.isScheme(SCHEME_USERNAME)
+                || extId.isScheme(SCHEME_USERNAME))
             ? true
             : null;
     return info;
@@ -1068,4 +1109,17 @@
     externalIdReader.setFailOnLoad(true);
     return () -> externalIdReader.setFailOnLoad(false);
   }
+
+  private ObjectId readRevision() throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return ExternalIdReader.readRevision(repo);
+    }
+  }
+
+  private long getCommitTimeUs(ObjectId rev) throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(repo)) {
+      return TimeUnit.SECONDS.toMicros(rw.parseCommit(rev).getCommitTime());
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java
index 13353bd..cfee7da 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java
@@ -15,14 +15,17 @@
 package com.google.gerrit.acceptance.rest.binding;
 
 import static com.google.gerrit.acceptance.rest.util.RestApiCallHelper.execute;
+import static com.google.gerrit.acceptance.rest.util.RestCall.Method.GET;
 import static com.google.gerrit.acceptance.rest.util.RestCall.Method.PUT;
 import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithoutExpiration;
 import static org.apache.http.HttpStatus.SC_METHOD_NOT_ALLOWED;
+import static org.apache.http.HttpStatus.SC_NOT_FOUND;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.config.GerritConfigs;
 import com.google.gerrit.acceptance.rest.util.RestCall;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.extensions.common.ChangeInput;
@@ -53,51 +56,68 @@
       ImmutableList.of(
           RestCall.get("/accounts/%s"),
           RestCall.put("/accounts/%s"),
-          RestCall.get("/accounts/%s/detail"),
-          RestCall.get("/accounts/%s/name"),
-          RestCall.put("/accounts/%s/name"),
-          RestCall.delete("/accounts/%s/name"),
-          RestCall.get("/accounts/%s/username"),
-          RestCall.builder(PUT, "/accounts/%s/username")
-              // Changing the username is not allowed.
-              .expectedResponseCode(SC_METHOD_NOT_ALLOWED)
-              .expectedMessage("Username cannot be changed.")
-              .build(),
           RestCall.get("/accounts/%s/active"),
           RestCall.put("/accounts/%s/active"),
           RestCall.delete("/accounts/%s/active"),
-          RestCall.put("/accounts/%s/password.http"),
-          RestCall.delete("/accounts/%s/password.http"),
-          RestCall.get("/accounts/%s/status"),
-          RestCall.put("/accounts/%s/status"),
-          RestCall.get("/accounts/%s/avatar"),
-          RestCall.get("/accounts/%s/avatar.change.url"),
+          RestCall.get("/accounts/%s/agreements"),
+          RestCall.put("/accounts/%s/agreements"),
+
+          // TODO: The avatar REST endpoints always returns '404 Not Found' because no avatar plugin
+          // is installed.
+          RestCall.builder(GET, "/accounts/%s/avatar").expectedResponseCode(SC_NOT_FOUND).build(),
+          RestCall.builder(GET, "/accounts/%s/avatar.change.url")
+              .expectedResponseCode(SC_NOT_FOUND)
+              .build(),
+          RestCall.get("/accounts/%s/capabilities"),
+          RestCall.get("/accounts/%s/capabilities/viewPlugins"),
+          RestCall.put("/accounts/%s/displayname"),
+          RestCall.get("/accounts/%s/detail"),
+          RestCall.post("/accounts/%s/drafts:delete"),
           RestCall.get("/accounts/%s/emails/"),
           RestCall.put("/accounts/%s/emails/new-email@foo.com"),
-          RestCall.get("/accounts/%s/sshkeys/"),
-          RestCall.post("/accounts/%s/sshkeys/"),
-          RestCall.get("/accounts/%s/watched.projects"),
-          RestCall.post("/accounts/%s/watched.projects"),
-          RestCall.post("/accounts/%s/watched.projects:delete"),
+          RestCall.get("/accounts/%s/external.ids"),
+          RestCall.post("/accounts/%s/external.ids:delete"),
+          RestCall.get("/accounts/%s/gpgkeys"),
+          RestCall.post("/accounts/%s/gpgkeys"),
           RestCall.get("/accounts/%s/groups"),
+          RestCall.post("/accounts/%s/index"),
+          RestCall.get("/accounts/%s/name"),
+          RestCall.put("/accounts/%s/name"),
+          RestCall.delete("/accounts/%s/name"),
+
+          // TODO: The oauthtoken REST endpoint always returns '404 Not Found' because no oauth
+          // token is available for the test user.
+          RestCall.builder(GET, "/accounts/%s/oauthtoken")
+              .expectedResponseCode(SC_NOT_FOUND)
+              .build(),
+
+          // The password.http REST endpoints must be tested separately, since changing/deleting the
+          // HTTP password breaks all further calls.
+          // See tests updateHttpPasswordEndpoints and deleteHttpPasswordEndpoints.
+
           RestCall.get("/accounts/%s/preferences"),
           RestCall.put("/accounts/%s/preferences"),
           RestCall.get("/accounts/%s/preferences.diff"),
           RestCall.put("/accounts/%s/preferences.diff"),
           RestCall.get("/accounts/%s/preferences.edit"),
           RestCall.put("/accounts/%s/preferences.edit"),
+          RestCall.get("/accounts/%s/sshkeys/"),
+          RestCall.post("/accounts/%s/sshkeys/"),
           RestCall.get("/accounts/%s/starred.changes"),
-          RestCall.post("/accounts/%s/index"),
-          RestCall.get("/accounts/%s/agreements"),
-          RestCall.put("/accounts/%s/agreements"),
-          RestCall.get("/accounts/%s/external.ids"),
-          RestCall.post("/accounts/%s/external.ids:delete"),
-          RestCall.post("/accounts/%s/drafts:delete"),
-          RestCall.get("/accounts/%s/oauthtoken"),
-          RestCall.get("/accounts/%s/capabilities"),
-          RestCall.get("/accounts/%s/capabilities/viewPlugins"),
-          RestCall.get("/accounts/%s/gpgkeys"),
-          RestCall.post("/accounts/%s/gpgkeys"));
+          RestCall.get("/accounts/%s/status"),
+          RestCall.put("/accounts/%s/status"),
+          RestCall.get("/accounts/%s/username"),
+          // Changing the username is not allowed.
+          RestCall.builder(PUT, "/accounts/%s/username")
+              .expectedResponseCode(SC_METHOD_NOT_ALLOWED)
+              .expectedMessage("Username cannot be changed.")
+              .build(),
+          RestCall.get("/accounts/%s/watched.projects"),
+          RestCall.post("/accounts/%s/watched.projects"),
+          RestCall.post("/accounts/%s/watched.projects:delete"),
+
+          // Account deletion must be the last tested endpoint
+          RestCall.delete("/accounts/%s"));
 
   /**
    * Email REST endpoints to be tested, each URL contains a placeholders for the account and email
@@ -144,11 +164,27 @@
           RestCall.delete("/accounts/%s/starred.changes/%s"));
 
   @Test
+  @GerritConfigs(
+      value = {
+        @GerritConfig(name = "auth.contributorAgreements", value = "true"),
+        @GerritConfig(name = "auth.registerEmailPrivateKey", value = "KEY"),
+        @GerritConfig(name = "receive.enableSignedPush", value = "true"),
+      })
   public void accountEndpoints() throws Exception {
     execute(adminRestSession, ACCOUNT_ENDPOINTS, "self");
   }
 
   @Test
+  public void updateHttpPasswordEndpoints() throws Exception {
+    execute(adminRestSession, RestCall.put("/accounts/%s/password.http"), "self");
+  }
+
+  @Test
+  public void deleteHttpPasswordEndpoints() throws Exception {
+    execute(adminRestSession, RestCall.delete("/accounts/%s/password.http"), "self");
+  }
+
+  @Test
   public void emailEndpoints() throws Exception {
     execute(adminRestSession, EMAIL_ENDPOINTS, "self", admin.email());
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
index 27bd6b9..6a5441c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
@@ -54,53 +54,57 @@
   private static final ImmutableList<RestCall> CHANGE_ENDPOINTS =
       ImmutableList.of(
           RestCall.get("/changes/%s"),
-          RestCall.get("/changes/%s/detail"),
-          RestCall.get("/changes/%s/topic"),
-          RestCall.put("/changes/%s/topic"),
-          RestCall.delete("/changes/%s/topic"),
-          RestCall.get("/changes/%s/in"),
-          RestCall.get("/changes/%s/hashtags"),
-          RestCall.get("/changes/%s/comments"),
-          RestCall.get("/changes/%s/robotcomments"),
-          RestCall.get("/changes/%s/drafts"),
+          RestCall.post("/changes/%s/abandon"),
           RestCall.get("/changes/%s/attention"),
           RestCall.post("/changes/%s/attention"),
+          RestCall.get("/changes/%s/check"),
+          RestCall.post("/changes/%s/check"),
+          RestCall.post("/changes/%s/check.submit_requirement"),
+          RestCall.get("/changes/%s/comments"),
+          RestCall.get("/changes/%s/custom_keyed_values"),
+          RestCall.post("/changes/%s/custom_keyed_values"),
+          RestCall.get("/changes/%s/detail"),
+          RestCall.get("/changes/%s/drafts"),
+          RestCall.get("/changes/%s/edit"),
+          RestCall.post("/changes/%s/edit"),
+          RestCall.put("/changes/%s/edit/a.txt"),
+          RestCall.get("/changes/%s/edit:message"),
+          RestCall.put("/changes/%s/edit:message"),
+          RestCall.post("/changes/%s/edit:publish"),
+          RestCall.post("/changes/%s/edit:rebase"),
+          RestCall.get("/changes/%s/hashtags"),
+          RestCall.get("/changes/%s/in"),
+          RestCall.post("/changes/%s/index"),
+          RestCall.get("/changes/%s/meta_diff"),
+          RestCall.post("/changes/%s/merge"),
+          RestCall.get("/changes/%s/messages"),
+          RestCall.put("/changes/%s/message"),
+          RestCall.post("/changes/%s/move"),
+          RestCall.post("/changes/%s/patch:apply"),
           RestCall.post("/changes/%s/private"),
           RestCall.post("/changes/%s/private.delete"),
           RestCall.delete("/changes/%s/private"),
-          RestCall.post("/changes/%s/wip"),
+          RestCall.get("/changes/%s/pure_revert"),
           RestCall.post("/changes/%s/ready"),
-          RestCall.get("/changes/%s/messages"),
-          RestCall.put("/changes/%s/message"),
-          RestCall.post("/changes/%s/merge"),
-          RestCall.post("/changes/%s/abandon"),
-          RestCall.post("/changes/%s/move"),
           RestCall.post("/changes/%s/rebase"),
+          RestCall.post("/changes/%s/rebase:chain"),
           RestCall.post("/changes/%s/restore"),
           RestCall.post("/changes/%s/revert"),
           RestCall.post("/changes/%s/revert_submission"),
-          RestCall.get("/changes/%s/pure_revert"),
-          RestCall.post("/changes/%s/submit"),
-          RestCall.get("/changes/%s/submitted_together"),
-          RestCall.post("/changes/%s/index"),
-          RestCall.get("/changes/%s/check"),
-          RestCall.post("/changes/%s/check"),
           RestCall.get("/changes/%s/reviewers"),
           RestCall.post("/changes/%s/reviewers"),
+          // GET /changes/<change-id>/revisions is not implemented
+          RestCall.builder(GET, "/changes/%s/revisions").expectedResponseCode(SC_NOT_FOUND).build(),
+          RestCall.get("/changes/%s/robotcomments"),
+          RestCall.get("/changes/%s/topic"),
+          RestCall.put("/changes/%s/topic"),
+          RestCall.delete("/changes/%s/topic"),
+          RestCall.post("/changes/%s/submit"),
+          RestCall.get("/changes/%s/submitted_together"),
           RestCall.get("/changes/%s/suggest_reviewers"),
-          RestCall.builder(GET, "/changes/%s/revisions")
-              // GET /changes/<change-id>/revisions is not implemented
-              .expectedResponseCode(SC_NOT_FOUND)
-              .build(),
-          RestCall.get("/changes/%s/edit"),
-          RestCall.post("/changes/%s/edit"),
-          RestCall.post("/changes/%s/edit:rebase"),
-          RestCall.get("/changes/%s/edit:message"),
-          RestCall.put("/changes/%s/edit:message"),
-
-          // Publish edit and create a new edit
-          RestCall.post("/changes/%s/edit:publish"),
-          RestCall.put("/changes/%s/edit/a.txt"),
+          // GET /changes/<change-id>/votes is not implemented
+          RestCall.builder(GET, "/changes/%s/votes").expectedResponseCode(SC_NOT_FOUND).build(),
+          RestCall.post("/changes/%s/wip"),
 
           // Deletion of change edit and change must be tested last
           RestCall.delete("/changes/%s/edit"),
@@ -113,9 +117,9 @@
   private static final ImmutableList<RestCall> REVIEWER_ENDPOINTS =
       ImmutableList.of(
           RestCall.get("/changes/%s/reviewers/%s"),
-          RestCall.get("/changes/%s/reviewers/%s/votes"),
+          RestCall.delete("/changes/%s/reviewers/%s"),
           RestCall.post("/changes/%s/reviewers/%s/delete"),
-          RestCall.delete("/changes/%s/reviewers/%s"));
+          RestCall.get("/changes/%s/reviewers/%s/votes"));
 
   /**
    * Vote REST endpoints to be tested, each URL contains placeholders for the change identifier, the
@@ -133,36 +137,36 @@
   private static final ImmutableList<RestCall> REVISION_ENDPOINTS =
       ImmutableList.of(
           RestCall.get("/changes/%s/revisions/%s/actions"),
+          RestCall.get("/changes/%s/revisions/%s/archive"),
           RestCall.post("/changes/%s/revisions/%s/cherrypick"),
+          RestCall.get("/changes/%s/revisions/%s/comments"),
           RestCall.get("/changes/%s/revisions/%s/commit"),
+          RestCall.get("/changes/%s/revisions/%s/description"),
+          RestCall.put("/changes/%s/revisions/%s/description"),
+          RestCall.get("/changes/%s/revisions/%s/drafts"),
+          RestCall.put("/changes/%s/revisions/%s/drafts"),
+          RestCall.get("/changes/%s/revisions/%s/files"),
+          // GET /changes/<change>/revisions/<revision>/fixes is not implemented
+          RestCall.builder(GET, "/changes/%s/revisions/%s/fixes")
+              .expectedResponseCode(SC_NOT_FOUND)
+              .build(),
+          RestCall.post("/changes/%s/revisions/%s/fix:apply"),
+          RestCall.post("/changes/%s/revisions/%s/fix:preview"),
           RestCall.get("/changes/%s/revisions/%s/mergeable"),
+          RestCall.get("/changes/%s/revisions/%s/mergelist"),
+          RestCall.get("/changes/%s/revisions/%s/patch"),
+          RestCall.get("/changes/%s/revisions/%s/ported_comments"),
+          RestCall.get("/changes/%s/revisions/%s/ported_drafts"),
+          RestCall.post("/changes/%s/revisions/%s/rebase"),
           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/reviewers"),
+          RestCall.get("/changes/%s/revisions/%s/robotcomments"),
           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"),
-          RestCall.get("/changes/%s/revisions/%s/archive"),
-          RestCall.get("/changes/%s/revisions/%s/mergelist"),
-          RestCall.get("/changes/%s/revisions/%s/reviewers"),
-          RestCall.get("/changes/%s/revisions/%s/drafts"),
-          RestCall.put("/changes/%s/revisions/%s/drafts"),
-          RestCall.get("/changes/%s/revisions/%s/comments"),
-          RestCall.get("/changes/%s/revisions/%s/robotcomments"),
-          RestCall.get("/changes/%s/revisions/%s/ported_comments"),
-          RestCall.get("/changes/%s/revisions/%s/ported_drafts"),
-          RestCall.builder(GET, "/changes/%s/revisions/%s/fixes")
-              // GET /changes/<change>/revisions/<revision>/fixes is not implemented
-              .expectedResponseCode(SC_NOT_FOUND)
-              .build(),
-          RestCall.get("/changes/%s/revisions/%s/files"));
+          RestCall.post("/changes/%s/revisions/%s/test.submit_type"));
 
   /**
    * Revision reviewer REST endpoints to be tested, each URL contains placeholders for the change
@@ -171,8 +175,8 @@
   private static final ImmutableList<RestCall> REVISION_REVIEWER_ENDPOINTS =
       ImmutableList.of(
           RestCall.get("/changes/%s/revisions/%s/reviewers/%s"),
-          RestCall.get("/changes/%s/revisions/%s/reviewers/%s/votes"),
           RestCall.post("/changes/%s/revisions/%s/reviewers/%s/delete"),
+          RestCall.get("/changes/%s/revisions/%s/reviewers/%s/votes"),
           RestCall.delete("/changes/%s/revisions/%s/reviewers/%s"));
 
   /**
@@ -224,12 +228,12 @@
    */
   private static final ImmutableList<RestCall> REVISION_FILE_ENDPOINTS =
       ImmutableList.of(
-          RestCall.put("/changes/%s/revisions/%s/files/%s/reviewed"),
-          RestCall.delete("/changes/%s/revisions/%s/files/%s/reviewed"),
+          RestCall.get("/changes/%s/revisions/%s/files/%s/blame"),
           RestCall.get("/changes/%s/revisions/%s/files/%s/content"),
-          RestCall.get("/changes/%s/revisions/%s/files/%s/download"),
           RestCall.get("/changes/%s/revisions/%s/files/%s/diff"),
-          RestCall.get("/changes/%s/revisions/%s/files/%s/blame"));
+          RestCall.get("/changes/%s/revisions/%s/files/%s/download"),
+          RestCall.put("/changes/%s/revisions/%s/files/%s/reviewed"),
+          RestCall.delete("/changes/%s/revisions/%s/files/%s/reviewed"));
 
   /**
    * Change message REST endpoints to be tested, each URL contains placeholders for the change
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
index 00dcb4f..57279d3 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
@@ -46,7 +46,12 @@
    */
   private static final ImmutableList<RestCall> CONFIG_ENDPOINTS =
       ImmutableList.of(
-          RestCall.get("/config/server/version"),
+          RestCall.get("/config/server/caches"),
+          RestCall.post("/config/server/caches"),
+          RestCall.get("/config/server/capabilities"),
+          RestCall.post("/config/server/check.consistency"),
+          RestCall.put("/config/server/email.confirm"),
+          RestCall.post("/config/server/index.changes"),
           RestCall.get("/config/server/info"),
           RestCall.get("/config/server/preferences"),
           RestCall.put("/config/server/preferences"),
@@ -54,28 +59,22 @@
           RestCall.put("/config/server/preferences.diff"),
           RestCall.get("/config/server/preferences.edit"),
           RestCall.put("/config/server/preferences.edit"),
-          RestCall.get("/config/server/top-menus"),
-          RestCall.put("/config/server/email.confirm"),
-          RestCall.post("/config/server/check.consistency"),
           RestCall.post("/config/server/reload"),
           RestCall.get("/config/server/summary"),
-          RestCall.get("/config/server/capabilities"),
-          RestCall.get("/config/server/caches"),
-          RestCall.post("/config/server/caches"),
           RestCall.get("/config/server/tasks"),
-          RestCall.post("/config/server/index.changes"));
+          RestCall.get("/config/server/top-menus"),
+          RestCall.get("/config/server/version"));
 
   /**
    * Cache REST endpoints to be tested, the URLs contain a placeholder for the cache identifier.
-   * Since there is only supported a single supported config identifier ('server') it can be
-   * hard-coded.
+   * Since there is only a single supported config identifier ('server') it can be hard-coded.
    */
   private static final ImmutableList<RestCall> CACHE_ENDPOINTS =
       ImmutableList.of(RestCall.get("/config/server/caches/%s"));
 
   /**
    * Task REST endpoints to be tested, the URLs contain a placeholder for the task identifier. Since
-   * there is only supported a single supported config identifier ('server') it can be hard-coded.
+   * there is only a single supported config identifier ('server') it can be hard-coded.
    */
   private static final ImmutableList<RestCall> TASK_ENDPOINTS =
       ImmutableList.of(
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/GroupsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/GroupsRestApiBindingsIT.java
index bb12172..74241d8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/GroupsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/GroupsRestApiBindingsIT.java
@@ -34,26 +34,26 @@
       ImmutableList.of(
           RestCall.get("/groups/%s"),
           RestCall.put("/groups/%s"),
-          RestCall.get("/groups/%s/detail"),
-          RestCall.get("/groups/%s/name"),
-          RestCall.put("/groups/%s/name"),
           RestCall.get("/groups/%s/description"),
           RestCall.put("/groups/%s/description"),
           RestCall.delete("/groups/%s/description"),
-          RestCall.get("/groups/%s/owner"),
-          RestCall.put("/groups/%s/owner"),
-          RestCall.get("/groups/%s/options"),
-          RestCall.put("/groups/%s/options"),
-          RestCall.post("/groups/%s/members"),
-          RestCall.post("/groups/%s/members.add"),
-          RestCall.post("/groups/%s/members.delete"),
+          RestCall.get("/groups/%s/detail"),
+          RestCall.get("/groups/%s/groups"),
           RestCall.post("/groups/%s/groups"),
           RestCall.post("/groups/%s/groups.add"),
           RestCall.post("/groups/%s/groups.delete"),
-          RestCall.get("/groups/%s/log.audit"),
           RestCall.post("/groups/%s/index"),
+          RestCall.get("/groups/%s/log.audit"),
           RestCall.get("/groups/%s/members"),
-          RestCall.get("/groups/%s/groups"));
+          RestCall.post("/groups/%s/members"),
+          RestCall.post("/groups/%s/members.add"),
+          RestCall.post("/groups/%s/members.delete"),
+          RestCall.get("/groups/%s/name"),
+          RestCall.put("/groups/%s/name"),
+          RestCall.get("/groups/%s/options"),
+          RestCall.put("/groups/%s/options"),
+          RestCall.get("/groups/%s/owner"),
+          RestCall.put("/groups/%s/owner"));
 
   /**
    * Member REST endpoints to be tested, each URL contains placeholders for the group identifier and
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ListProjectOptionsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ListProjectOptionsRestApiBindingsIT.java
new file mode 100644
index 0000000..1d209e1
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ListProjectOptionsRestApiBindingsIT.java
@@ -0,0 +1,109 @@
+// 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.acceptance.rest.binding;
+
+import static com.google.gerrit.acceptance.rest.util.RestCall.Method.GET;
+import static org.apache.http.HttpStatus.SC_BAD_REQUEST;
+import static org.apache.http.HttpStatus.SC_OK;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.rest.util.RestApiCallHelper;
+import com.google.gerrit.acceptance.rest.util.RestCall;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import org.junit.Test;
+
+public class ListProjectOptionsRestApiBindingsIT extends AbstractDaemonTest {
+  private static final ImmutableList<RestCall> LIST_PROJECTS_WITH_OPTIONS =
+      ImmutableList.of(
+          // =========================
+          // === Supported options ===
+          // =========================
+          get200OK("/projects/?show-branch=refs/heads/master"),
+          get200OK("/projects/?b=refs/heads/master"),
+          get200OK("/projects/?format=TEXT"),
+          get200OK("/projects/?format=JSON"),
+          get200OK("/projects/?format=JSON_COMPACT"),
+          get200OK("/projects/?tree"),
+          get200OK("/projects/?tree=true"),
+          get200OK("/projects/?tree=false"),
+          get200OK("/projects/?t"),
+          get200OK("/projects/?t=true"),
+          get200OK("/projects/?t=false"),
+          get200OK("/projects/?type=ALL"),
+          get200OK("/projects/?type=CODE"),
+          get200OK("/projects/?type=PERMISSIONS"),
+          get200OK("/projects/?description"),
+          get200OK("/projects/?description=true"),
+          get200OK("/projects/?description=false"),
+          get200OK("/projects/?d"),
+          get200OK("/projects/?d=true"),
+          get200OK("/projects/?d=false"),
+          get200OK("/projects/?all"),
+          get200OK("/projects/?all=true"),
+          get200OK("/projects/?all=false"),
+          get200OK("/projects/?state=ACTIVE"),
+          get200OK("/projects/?state=READ_ONLY"),
+          get200OK("/projects/?state=HIDDEN"),
+          get200OK("/projects/?limit=10"),
+          get200OK("/projects/?n=10"),
+          get200OK("/projects/?start=10"),
+          get200OK("/projects/?S=10"),
+          get200OK("/projects/?prefix=my-prefix"),
+          get200OK("/projects/?p=my-prefix"),
+          get200OK("/projects/?match=my-match"),
+          get200OK("/projects/?m=my-match"),
+          get200OK("/projects/?r=my-regex"),
+          get200OK("/projects/?has-acl-for=" + SystemGroupBackend.ANONYMOUS_USERS.get()),
+
+          // ===========================
+          // === Unsupported options ===
+          // ===========================
+          get400BadRequest("/projects/?unknown", "\"--unknown\" is not a valid option"),
+          get400BadRequest("/projects/?unknown", "\"--unknown\" is not a valid option"),
+          get400BadRequest(
+              "/projects/?format=UNKNOWN", "\"UNKNOWN\" is not a valid value for \"--format\""),
+          get400BadRequest("/projects/?tree=UNKNOWN", "invalid boolean \"tree=UNKNOWN\""),
+          get400BadRequest("/projects/?t=UNKNOWN", "invalid boolean \"t=UNKNOWN\""),
+          get400BadRequest(
+              "/projects/?type=UNKNOWN", "\"UNKNOWN\" is not a valid value for \"--type\""),
+          get400BadRequest(
+              "/projects/?description=UNKNOWN", "invalid boolean \"description=UNKNOWN\""),
+          get400BadRequest("/projects/?d=UNKNOWN", "invalid boolean \"d=UNKNOWN\""),
+          get400BadRequest("/projects/?all=UNKNOWN", "invalid boolean \"all=UNKNOWN\""),
+          get400BadRequest(
+              "/projects/?state=UNKNOWN", "\"UNKNOWN\" is not a valid value for \"--state\""),
+          get400BadRequest("/projects/?n=UNKNOWN", "\"UNKNOWN\" is not a valid value for \"-n\""),
+          get400BadRequest(
+              "/projects/?start=UNKNOWN", "\"UNKNOWN\" is not a valid value for \"--start\""),
+          get400BadRequest("/projects/?S=UNKNOWN", "\"UNKNOWN\" is not a valid value for \"-S\""),
+          get400BadRequest("/projects/?has-acl-for=UNKNOWN", "Group \"UNKNOWN\" does not exist"));
+
+  private static RestCall get200OK(String uriFormat) {
+    return RestCall.builder(GET, uriFormat).expectedResponseCode(SC_OK).build();
+  }
+
+  private static RestCall get400BadRequest(String uriFormat, String expectedMessage) {
+    return RestCall.builder(GET, uriFormat)
+        .expectedResponseCode(SC_BAD_REQUEST)
+        .expectedMessage(expectedMessage)
+        .build();
+  }
+
+  @Test
+  public void listProjectsWithOptions() throws Exception {
+    RestApiCallHelper.execute(adminRestSession, LIST_PROJECTS_WITH_OPTIONS);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
index cffcc2f..db9c1e7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
@@ -55,41 +55,42 @@
       ImmutableList.of(
           RestCall.get("/projects/%s"),
           RestCall.put("/projects/%s"),
-          RestCall.get("/projects/%s/description"),
-          RestCall.put("/projects/%s/description"),
-          RestCall.delete("/projects/%s/description"),
-          RestCall.get("/projects/%s/parent"),
-          RestCall.put("/projects/%s/parent"),
-          RestCall.get("/projects/%s/config"),
-          RestCall.put("/projects/%s/config"),
-          RestCall.get("/projects/%s/HEAD"),
-          RestCall.put("/projects/%s/HEAD"),
           RestCall.get("/projects/%s/access"),
           RestCall.post("/projects/%s/access"),
           RestCall.put("/projects/%s/access:review"),
-          RestCall.get("/projects/%s/check.access"),
           RestCall.put("/projects/%s/ban"),
-          RestCall.get("/projects/%s/statistics.git"),
-          RestCall.post("/projects/%s/index"),
-          RestCall.post("/projects/%s/gc"),
-          RestCall.post("/projects/%s/create.change"),
-          RestCall.get("/projects/%s/children"),
           RestCall.get("/projects/%s/branches"),
-          RestCall.post("/projects/%s/branches:delete"),
           RestCall.put("/projects/%s/branches/new-branch"),
-          RestCall.get("/projects/%s/labels"),
-          RestCall.get("/projects/%s/tags"),
-          RestCall.post("/projects/%s/tags:delete"),
-          RestCall.put("/projects/%s/tags/new-tag"),
-          RestCall.builder(GET, "/projects/%s/commits")
-              // GET /projects/<project>/branches/<branch>/commits is not implemented
-              .expectedResponseCode(SC_NOT_FOUND)
-              .build(),
+          RestCall.post("/projects/%s/branches:delete"),
+          RestCall.post("/projects/%s/check"),
+          RestCall.get("/projects/%s/check.access"),
+          RestCall.get("/projects/%s/children"),
+          // GET /projects/<project>/branches/<branch>/commits is not implemented
+          RestCall.builder(GET, "/projects/%s/commits").expectedResponseCode(SC_NOT_FOUND).build(),
+          RestCall.get("/projects/%s/commits:in"),
+          RestCall.get("/projects/%s/config"),
+          RestCall.put("/projects/%s/config"),
+          RestCall.post("/projects/%s/create.change"),
           RestCall.get("/projects/%s/dashboards"),
-          RestCall.put("/projects/%s/labels/new-label"),
+          RestCall.get("/projects/%s/description"),
+          RestCall.put("/projects/%s/description"),
+          RestCall.delete("/projects/%s/description"),
+          RestCall.post("/projects/%s/gc"),
+          RestCall.get("/projects/%s/HEAD"),
+          RestCall.put("/projects/%s/HEAD"),
+          RestCall.post("/projects/%s/index"),
+          RestCall.post("/projects/%s/index.changes"),
+          RestCall.get("/projects/%s/labels"),
           RestCall.post("/projects/%s/labels/"),
+          RestCall.put("/projects/%s/labels/new-label"),
+          RestCall.get("/projects/%s/parent"),
+          RestCall.put("/projects/%s/parent"),
+          RestCall.get("/projects/%s/statistics.git"),
+          RestCall.get("/projects/%s/submit_requirements"),
           RestCall.put("/projects/%s/submit_requirements/new-sr"),
-          RestCall.get("/projects/%s/submit_requirements"));
+          RestCall.get("/projects/%s/tags"),
+          RestCall.put("/projects/%s/tags/new-tag"),
+          RestCall.post("/projects/%s/tags:delete"));
 
   /**
    * Child project REST endpoints to be tested, each URL contains placeholders for the parent
@@ -106,16 +107,17 @@
       ImmutableList.of(
           RestCall.get("/projects/%s/branches/%s"),
           RestCall.put("/projects/%s/branches/%s"),
+          // GET /projects/<project>/branches/<branch>/files is not implemented
+          RestCall.builder(GET, "/projects/%s/branches/%s/files")
+              .expectedResponseCode(SC_NOT_FOUND)
+              .build(),
           RestCall.get("/projects/%s/branches/%s/mergeable"),
+          // The tests use DfsRepository which does not support getting the reflog.
           RestCall.builder(GET, "/projects/%s/branches/%s/reflog")
-              // The tests use DfsRepository which does not support getting the reflog.
               .expectedResponseCode(SC_METHOD_NOT_ALLOWED)
               .expectedMessage("reflog not supported on")
               .build(),
-          RestCall.builder(GET, "/projects/%s/branches/%s/files")
-              // GET /projects/<project>/branches/<branch>/files is not implemented
-              .expectedResponseCode(SC_NOT_FOUND)
-              .build(),
+          RestCall.get("/projects/%s/branches/%s/suggest_reviewers"),
 
           // Branch deletion must be tested last
           RestCall.delete("/projects/%s/branches/%s"));
@@ -156,9 +158,9 @@
   private static final ImmutableList<RestCall> COMMIT_ENDPOINTS =
       ImmutableList.of(
           RestCall.get("/projects/%s/commits/%s"),
-          RestCall.get("/projects/%s/commits/%s/in"),
+          RestCall.post("/projects/%s/commits/%s/cherrypick"),
           RestCall.get("/projects/%s/commits/%s/files"),
-          RestCall.post("/projects/%s/commits/%s/cherrypick"));
+          RestCall.get("/projects/%s/commits/%s/in"));
 
   /**
    * Commit file REST endpoints to be tested, each URL contains placeholders for the project
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 585d704..ecae27e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -25,6 +25,7 @@
 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.extensions.client.ListChangesOption.SUBMITTABLE;
+import static com.google.gerrit.extensions.client.SubmitType.CHERRY_PICK;
 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;
@@ -49,6 +50,7 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.TestMetricMaker;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.UseTimezone;
@@ -66,6 +68,7 @@
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -90,6 +93,7 @@
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.TestSubmitInput;
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
+import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.restapi.change.Submit;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -139,6 +143,9 @@
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private Submit submitHandler;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject TestMetricMaker testMetricMaker;
+
+  @Inject private ChangeIndexer changeIndex;
 
   protected abstract SubmitType getSubmitType();
 
@@ -455,7 +462,7 @@
     submit(change4.getChangeId());
     String expectedTopic1 = name(topic1);
     String expectedTopic2 = name(topic2);
-    if (getSubmitType() == SubmitType.CHERRY_PICK) {
+    if (getSubmitType() == CHERRY_PICK) {
       change1.assertChange(Change.Status.NEW, expectedTopic1, admin);
       change2.assertChange(Change.Status.NEW, expectedTopic1, admin);
 
@@ -468,7 +475,7 @@
     assertSubmitter(change4);
     // Also check submitters for changes submitted via the topic relationship.
     assertSubmitter(change3);
-    if (getSubmitType() != SubmitType.CHERRY_PICK) {
+    if (getSubmitType() != CHERRY_PICK) {
       assertSubmitter(change1);
       assertSubmitter(change2);
     }
@@ -498,7 +505,7 @@
     }
     assertThat(log).hasSize(expectedCommitCount);
 
-    if (getSubmitType() == SubmitType.CHERRY_PICK) {
+    if (getSubmitType() == CHERRY_PICK) {
       assertThat(commitsInRepo).containsAtLeast("Initial empty repository", "Change 3", "Change 4");
       assertThat(commitsInRepo).doesNotContain("Change 1");
       assertThat(commitsInRepo).doesNotContain("Change 2");
@@ -570,7 +577,7 @@
     PushOneCommit.Result parent = pushTo("refs/for/master%wip");
     PushOneCommit.Result change = createChange();
     Change.Id num = parent.getChange().getId();
-    if (getSubmitType() == SubmitType.CHERRY_PICK) {
+    if (getSubmitType() == CHERRY_PICK) {
       submit(change.getChangeId());
     } else {
       submitWithConflict(
@@ -731,7 +738,7 @@
     PushOneCommit.Result change1 = createChange();
     PushOneCommit.Result change2 = createChange();
     approve(change1.getChangeId());
-    if (getSubmitType() == SubmitType.CHERRY_PICK) {
+    if (getSubmitType() == CHERRY_PICK) {
       submit(change1.getChangeId());
     }
     submit(change2.getChangeId());
@@ -941,6 +948,7 @@
 
   @Test
   public void retrySubmitSingleChangeOnLockFailure() throws Throwable {
+
     PushOneCommit.Result change = createChange();
     String id = change.getChangeId();
     approve(id);
@@ -965,6 +973,35 @@
   }
 
   @Test
+  public void submitChangeMissingInIndexComputeMergeSupersetRetried() throws Throwable {
+    // Cherry-pick strategy does not query from index
+    assume().that(getSubmitType()).isNotEqualTo(CHERRY_PICK);
+    // retry on index
+    PushOneCommit.Result change = createChange();
+
+    // Submit using full change Id to avoid using index.
+    String id = change.getChange().project() + "~" + change.getChange().getId().get();
+    approve(id);
+    changeIndex.delete(change.getChange().getId());
+
+    TestSubmitInput input = new TestSubmitInput();
+
+    testMetricMaker.reset();
+
+    Throwable thrown = assertThrows(StorageException.class, () -> submit(id, input));
+    assertThat(thrown.getCause()).hasMessageThat().contains("missing from ChangeSet[][]");
+
+    // We retried more than once before giving up
+    assertThat(
+            testMetricMaker.getCount(
+                "action/retry_attempt_count",
+                "INDEX_QUERY",
+                "completeMergeChangeSet",
+                "IOException"))
+        .isGreaterThan(1);
+  }
+
+  @Test
   public void retrySubmitAfterTornTopicOnLockFailure() throws Throwable {
     assume().that(isSubmitWholeTopicEnabled()).isTrue();
 
@@ -1257,7 +1294,7 @@
     assertThat(info.messages).isNotNull();
     Iterable<String> messages = Iterables.transform(info.messages, i -> i.message);
     String last = Iterables.getLast(messages);
-    if (getSubmitType() == SubmitType.CHERRY_PICK) {
+    if (getSubmitType() == CHERRY_PICK) {
       assertThat(last).startsWith("Change has been successfully cherry-picked as");
     } else if (getSubmitType() == SubmitType.REBASE_ALWAYS) {
       assertThat(last).startsWith("Change has been successfully rebased and submitted as");
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
index 9c496fa..9d98ecb5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
@@ -120,7 +120,7 @@
   }
 
   @Test
-  public void dependencyOnOutdatedPatchSetPreventsMerge() throws Throwable {
+  public void dependencyOnOutdatedPatchSetOfUnsubmittedChangePreventsMerge() throws Throwable {
     // Create a change
     PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
     PushOneCommit.Result changeResult = change.to("refs/for/master");
@@ -147,7 +147,7 @@
         "Failed to submit 2 changes due to the following problems:\n"
             + "Change "
             + change2Result.getChange().getId()
-            + ": Depends on change that was not submitted."
+            + ": Depends on commit that cannot be merged."
             + " Commit "
             + change2Result.getCommit().name()
             + " depends on commit "
@@ -163,4 +163,56 @@
     assertRefUpdatedEvents();
     assertChangeMergedEvents();
   }
+
+  @Test
+  public void dependencyOnOutdatedPatchSetOfSubmittedChangePreventsMerge() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+
+    // Create a change
+    PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
+    PushOneCommit.Result changeResult = change.to("refs/for/master");
+    PatchSet.Id patchSetId = changeResult.getPatchSetId();
+
+    // Create a successor change.
+    PushOneCommit change2 =
+        pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
+    PushOneCommit.Result change2Result = change2.to("refs/for/master");
+
+    // Create new patch set for first change.
+    testRepo.reset(changeResult.getCommit().name());
+    amendChange(changeResult.getChangeId());
+
+    // Approve and submit the first changes
+    approve(changeResult.getChangeId());
+    submit(changeResult.getChangeId());
+    RevCommit headAfterSubmit = projectOperations.project(project).getHead("master");
+
+    // Approve the second change
+    approve(change2Result.getChangeId());
+
+    // submit button is disabled.
+    assertSubmitDisabled(change2Result.getChangeId());
+
+    submitWithConflict(
+        change2Result.getChangeId(),
+        "Failed to submit 1 change due to the following problems:\n"
+            + "Change "
+            + change2Result.getChange().getId()
+            + ": Depends on commit that cannot be merged."
+            + " Commit "
+            + change2Result.getCommit().name()
+            + " depends on commit "
+            + changeResult.getCommit().name()
+            + ", which is outdated patch set "
+            + patchSetId.get()
+            + " of change "
+            + changeResult.getChange().getId()
+            + ". The latest patch set is "
+            + changeResult.getPatchSetId().get()
+            + ".");
+
+    // Only events for the first change are sent.
+    assertRefUpdatedEvents(initialHead, headAfterSubmit);
+    assertChangeMergedEvents(changeResult.getChangeId(), headAfterSubmit.name());
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index aeebc10..a30b5c4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -417,7 +417,7 @@
         "Failed to submit 2 changes due to the following problems:\n"
             + "Change "
             + change2Result.getChange().getId()
-            + ": Depends on change that was not submitted."
+            + ": Depends on commit that cannot be merged."
             + " Commit "
             + change2Result.getCommit().name()
             + " depends on commit "
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index 517ebd5..a5c687b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -52,6 +52,7 @@
 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.CustomKeyedValuesInput;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
@@ -67,6 +68,7 @@
 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.change.ReaddOwnerUtil;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
@@ -102,6 +104,7 @@
   @Inject private Provider<InternalChangeQuery> changeQueryProvider;
   @Inject private ProjectOperations projectOperations;
   @Inject private GetAttentionSet getAttentionSet;
+  @Inject private ReaddOwnerUtil readdOwnerUtil;
 
   /** Simulates a fake clock. Uses second granularity. */
   private static class FakeClock implements LongSupplier {
@@ -687,9 +690,9 @@
   }
 
   @Test
-  public void reviewersAreNotAddedForNoReasonBecauseOfAnUpdate() throws Exception {
+  public void reviewersAreNotAddedForNoReasonBecauseOfAHashtagUpdate() throws Exception {
     PushOneCommit.Result r = createChange();
-    // implictly adds the user to the attention set when adding as reviewer
+    // implicitly adds the user to the attention set when adding as reviewer
     change(r).addReviewer(user.email());
 
     change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
@@ -705,6 +708,24 @@
   }
 
   @Test
+  public void reviewersAreNotAddedForNoReasonBecauseOfACustomKeyedValuesUpdate() throws Exception {
+    PushOneCommit.Result r = createChange();
+    // implicitly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
+
+    change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
+
+    CustomKeyedValuesInput customKeyedValuesInput = new CustomKeyedValuesInput();
+    customKeyedValuesInput.add = ImmutableMap.of("key1", "value1");
+    change(r).setCustomKeyedValues(customKeyedValuesInput);
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("removed");
+  }
+
+  @Test
   public void reviewAddsManuallyAddedUserToAttentionSet() throws Exception {
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(user.id());
@@ -2810,6 +2831,44 @@
         .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
   }
 
+  @Test
+  @GerritConfig(name = "attentionSet.readdOwnerAfter", value = "1w")
+  @GerritConfig(name = "attentionSet.readdOwnerMessage", value = "Owner has been added")
+  public void readdOwnerForInactiveOpenChanges() throws Exception {
+    // create 2 changes where the owner will be added to the attention-set
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange();
+
+    // ... because they are older than 1 week
+    fakeClock.advance(Duration.ofDays(7));
+
+    // create 1 change where the owner should not be added to the attention-set
+    PushOneCommit.Result r3 = createChange();
+
+    assertThat(r1.getChange().attentionSet()).isEmpty();
+    assertThat(r2.getChange().attentionSet()).isEmpty();
+    assertThat(r3.getChange().attentionSet()).isEmpty();
+
+    sender.clear();
+    readdOwnerUtil.readdOwnerForInactiveOpenChanges(batchUpdateFactory);
+    assertThat(r1.getChange().attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Owner has been added"));
+    assertThat(r2.getChange().attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Owner has been added"));
+    assertThat(r3.getChange().attentionSet()).isEmpty();
+    assertThat(sender.getMessages()).hasSize(2);
+  }
+
   private void setEmailStrategyForUser(EmailStrategy es) throws Exception {
     requestScopeOperations.setApiUser(user.id());
     GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index 985baba..b1b1887 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -21,7 +21,11 @@
 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.extensions.common.testing.ChangeInfoSubject.assertThat;
+import static com.google.gerrit.extensions.common.testing.ChangeInfoSubject.vote;
 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 javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
@@ -39,6 +43,7 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -100,14 +105,14 @@
     // Attempt to add overly large group as reviewers.
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
-    ReviewerResult result = addReviewer(changeId, largeGroup);
+    ReviewerResult result = addReviewer(changeId, largeGroup, SC_BAD_REQUEST);
     assertThat(result.input).isEqualTo(largeGroup);
     assertThat(result.confirm).isNull();
     assertThat(result.error).contains("has too many members to add them all as reviewers");
     assertThat(result.reviewers).isNull();
 
     // Attempt to add medium group without confirmation.
-    result = addReviewer(changeId, mediumGroup);
+    result = addReviewer(changeId, mediumGroup, SC_BAD_REQUEST);
     assertThat(result.input).isEqualTo(mediumGroup);
     assertThat(result.confirm).isTrue();
     assertThat(result.error)
@@ -157,6 +162,48 @@
   }
 
   @Test
+  public void addCcEmailWithoutAccount() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String testEmailAddress = "email@without.account";
+
+    // Add a reviewer
+    ReviewerInput ri = new ReviewerInput();
+    ri.reviewer = user.email();
+    ri.state = REVIEWER;
+    ReviewerResult result = addReviewer(changeId, ri);
+    assertThat(result.input).isEqualTo(user.email());
+    assertThat(result.confirm).isNull();
+    assertThat(result.error).isNull();
+    assertThat(result.reviewers).hasSize(1);
+
+    // Add an email address that has no account to CC
+    ReviewerInput ccInput = new ReviewerInput();
+    ccInput.reviewer = testEmailAddress;
+    ccInput.state = CC;
+    ReviewerResult resultCC = addReviewer(changeId, ccInput, SC_BAD_REQUEST);
+    assertThat(resultCC.error).contains("Account '" + testEmailAddress + "' not found");
+    assertThat(resultCC.error)
+        .contains(testEmailAddress + " does not identify a registered user or group");
+  }
+
+  @Test
+  public void addReviewerEmailWithoutAccount() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    String testEmailAddress = "email@without.account";
+
+    // Add a reviewer without an account
+    ReviewerInput ri = new ReviewerInput();
+    ri.reviewer = testEmailAddress;
+    ri.state = REVIEWER;
+    ReviewerResult result = addReviewer(changeId, ri, SC_BAD_REQUEST);
+    assertThat(result.error).contains("Account '" + testEmailAddress + "' not found");
+    assertThat(result.error)
+        .contains(testEmailAddress + " does not identify a registered user or group");
+  }
+
+  @Test
   public void addCcGroup() throws Exception {
     List<TestAccount> users = createAccounts(6, "addCcGroup");
     List<String> usernames = new ArrayList<>(6);
@@ -221,7 +268,7 @@
     for (int i = 0; i < 3; i++) {
       expectedAddresses.add(users.get(users.size() - i - 1).getNameEmail());
     }
-    expectedAddresses.add(reviewer.getNameEmail());
+    // 'reviewer' is not included in the email, since it has already been notified.
     assertThat(m.rcpt()).containsExactlyElementsIn(expectedAddresses);
   }
 
@@ -879,6 +926,112 @@
   }
 
   @Test
+  public void removeReviewerWithVoteAndThenAddThemBackClearsVote() throws Exception {
+    // Add Verified label.
+    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();
+    }
+
+    // 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();
+
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.VERIFIED, 1).label(LabelId.CODE_REVIEW, 1));
+
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 2));
+
+    assertThat(gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS))
+        .hasExactlyVotes(
+            vote(LabelId.CODE_REVIEW, user.id(), 1),
+            vote(LabelId.VERIFIED, user.id(), 1),
+            vote(LabelId.CODE_REVIEW, admin.id(), 2));
+
+    gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
+    assertThat(gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS))
+        .hasExactlyVotes(vote(LabelId.CODE_REVIEW, admin.id(), 2));
+
+    ReviewerInput input = new ReviewerInput();
+    input.reviewer = user.email();
+    input.state = ReviewerState.REVIEWER;
+    ReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
+    assertThat(result.reviewers).hasSize(1);
+
+    assertThat(gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS))
+        .hasExactlyVotes(vote(LabelId.CODE_REVIEW, admin.id(), 2));
+  }
+
+  @Test
+  public void reviewerVotesAreReturnedIfReviewerIsAddedByVoting() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 1));
+    assertThat(gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS))
+        .hasExactlyVotes(vote(LabelId.CODE_REVIEW, admin.id(), 1));
+
+    gApi.changes().id(r.getChangeId()).reviewer(admin.email()).remove();
+    assertThat(gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS)).hasNoVotes();
+
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 2));
+    assertThat(gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS))
+        .hasExactlyVotes(vote(LabelId.CODE_REVIEW, admin.id(), 2));
+  }
+
+  @Test
+  public void reviewerVotesAreReturnedIfReviewerIsAddedAndThenVoted() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 1));
+    assertThat(gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS))
+        .hasExactlyVotes(vote(LabelId.CODE_REVIEW, user.id(), 1));
+
+    gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove();
+    assertThat(gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS)).hasNoVotes();
+
+    ReviewerInput input = new ReviewerInput();
+    input.reviewer = user.email();
+    input.state = ReviewerState.REVIEWER;
+    ReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
+    assertThat(result.reviewers).hasSize(1);
+    assertThat(gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS)).hasNoVotes();
+
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().label(LabelId.CODE_REVIEW, 1));
+    assertThat(gApi.changes().id(r.getChangeId()).get(DETAILED_LABELS))
+        .hasExactlyVotes(vote(LabelId.CODE_REVIEW, user.id(), 1));
+  }
+
+  @Test
   public void addExistingReviewerShortCircuits() throws Exception {
     PushOneCommit.Result r = createChange();
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index 1952b32..f8eccb5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -44,6 +44,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.UseSystemTime;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.BranchNameKey;
@@ -192,6 +193,16 @@
   }
 
   @Test
+  @GerritConfig(name = "change.topicLimit", value = "3")
+  public void createNewChange_exceedsTopicLimit() throws Exception {
+    assertCreateSucceeds(newChangeWithTopic("limited"));
+    assertCreateSucceeds(newChangeWithTopic("limited"));
+    assertCreateSucceeds(newChangeWithTopic("limited"));
+    ChangeInput ci = newChangeWithTopic("limited");
+    assertCreateFails(ci, BadRequestException.class, "topicLimit");
+  }
+
+  @Test
   public void createNewChange() throws Exception {
     ChangeInfo info = assertCreateSucceeds(newChangeInput(ChangeStatus.NEW));
     assertThat(info.revisions.get(info.currentRevision).commit.message)
@@ -1296,6 +1307,19 @@
     }
   }
 
+  @Test
+  public void createChangeWithCustomKeyedValues() throws Exception {
+    ChangeInput changeInput = new ChangeInput();
+    changeInput.project = project.get();
+    changeInput.branch = "master";
+    changeInput.subject = "A change";
+    changeInput.status = ChangeStatus.NEW;
+    changeInput.customKeyedValues = ImmutableMap.of("key", "value");
+
+    ChangeInfo result = assertCreateSucceeds(changeInput);
+    assertThat(result.customKeyedValues).containsExactly("key", "value");
+  }
+
   private ChangeInput newChangeInput(ChangeStatus status) {
     ChangeInput in = new ChangeInput();
     in.project = project.get();
@@ -1306,6 +1330,12 @@
     return in;
   }
 
+  private ChangeInput newChangeWithTopic(String topic) {
+    ChangeInput in = newChangeInput(ChangeStatus.NEW);
+    in.topic = topic;
+    return in;
+  }
+
   private ChangeInfo assertCreateSucceeds(ChangeInput in) throws Exception {
     ChangeInfo out = gApi.changes().create(in).get();
     validateCreateSucceeds(in, out);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CustomKeyedValuesIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CustomKeyedValuesIT.java
new file mode 100644
index 0000000..3a1d909
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CustomKeyedValuesIT.java
@@ -0,0 +1,287 @@
+// 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.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.server.notedb.ChangeUpdate.MAX_CUSTOM_KEYED_VALUES;
+import static com.google.gerrit.server.notedb.ChangeUpdate.MAX_CUSTOM_KEYED_VALUE_LENGTH;
+import static com.google.gerrit.server.notedb.ChangeUpdate.MAX_CUSTOM_KEY_LENGTH;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.truth.MapSubject;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.extensions.api.changes.CustomKeyedValuesInput;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+public class CustomKeyedValuesIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void getNoCustomKeyedValues() throws Exception {
+    // Get on a change with no custom keyed values returns an empty list.
+    PushOneCommit.Result r = createChange();
+    assertThatGet(r).isEmpty();
+  }
+
+  @Test
+  public void parsesInputCorrectly() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String endpoint = "/changes/" + r.getChangeId() + "/custom_keyed_values";
+    CustomKeyedValuesInput input = new CustomKeyedValuesInput();
+    input.add = ImmutableMap.of("key", "value");
+    RestResponse response = adminRestSession.post(endpoint, input);
+    response.assertOK();
+
+    assertThatGet(r).containsExactly("key", "value");
+  }
+
+  @Test
+  public void addSingleCustomKeyedValue() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeMessageInfo last = getLastMessage(r);
+
+    addCustomKeyedValues(r, ImmutableMap.of("key1", "value1"));
+    assertThatGet(r).containsExactly("key1", "value1");
+    assertNoNewMessageSince(r, last);
+
+    addCustomKeyedValues(r, ImmutableMap.of("key2", "value2"));
+    assertThatGet(r).containsExactly("key1", "value1", "key2", "value2");
+    assertNoNewMessageSince(r, last);
+  }
+
+  @Test
+  public void addInvalidCustomKeyedValue() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> addCustomKeyedValues(r, ImmutableMap.of("key=", "value")));
+    assertThat(thrown).hasMessageThat().contains("custom keys may not contain equals");
+  }
+
+  @Test
+  public void addMultipleCustomKeyedValues() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeMessageInfo last = getLastMessage(r);
+    addCustomKeyedValues(r, ImmutableMap.of("key1", "value1", "key2", "value2"));
+    assertThatGet(r).containsExactly("key1", "value1", "key2", "value2");
+    assertNoNewMessageSince(r, last);
+
+    addCustomKeyedValues(r, ImmutableMap.of("key3", "value3"));
+    assertThatGet(r).containsExactly("key1", "value1", "key2", "value2", "key3", "value3");
+    assertNoNewMessageSince(r, last);
+  }
+
+  @Test
+  public void addAlreadyExistingCustomKeyedValue() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeMessageInfo last = getLastMessage(r);
+    addCustomKeyedValues(r, ImmutableMap.of("key1", "value1"));
+    assertThatGet(r).containsExactly("key1", "value1");
+    assertNoNewMessageSince(r, last);
+
+    addCustomKeyedValues(r, ImmutableMap.of("key1", "value2"));
+    assertThatGet(r).containsExactly("key1", "value2");
+    assertNoNewMessageSince(r, last);
+  }
+
+  @Test
+  public void removeSingleCustomKeyedValue() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeMessageInfo last = getLastMessage(r);
+    addCustomKeyedValues(r, ImmutableMap.of("key1", "value1"));
+    assertThatGet(r).containsExactly("key1", "value1");
+    assertNoNewMessageSince(r, last);
+
+    removeCustomKeys(r, ImmutableSet.of("key1"));
+    assertThatGet(r).containsExactly();
+    assertNoNewMessageSince(r, last);
+
+    // Removing a single custom keyed value returns the other custom keyed values.
+    addCustomKeyedValues(r, ImmutableMap.of("key1", "value1", "key2", "value2"));
+    assertThatGet(r).containsExactly("key1", "value1", "key2", "value2");
+    assertNoNewMessageSince(r, last);
+
+    removeCustomKeys(r, ImmutableSet.of("key1"));
+    assertThatGet(r).containsExactly("key2", "value2");
+    assertNoNewMessageSince(r, last);
+  }
+
+  @Test
+  public void removeMultipleCustomKeys() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeMessageInfo last = getLastMessage(r);
+    addCustomKeyedValues(r, ImmutableMap.of("key1", "value1", "key2", "value2"));
+    assertThatGet(r).containsExactly("key1", "value1", "key2", "value2");
+    assertNoNewMessageSince(r, last);
+    removeCustomKeys(r, ImmutableSet.of("key1", "key2"));
+    assertThatGet(r).containsExactly();
+    assertNoNewMessageSince(r, last);
+
+    addCustomKeyedValues(r, ImmutableMap.of("key1", "value1", "key2", "value2", "key3", "value3"));
+    assertThatGet(r).containsExactly("key1", "value1", "key2", "value2", "key3", "value3");
+    assertNoNewMessageSince(r, last);
+    removeCustomKeys(r, ImmutableSet.of("key1", "key2"));
+    assertThatGet(r).containsExactly("key3", "value3");
+    assertNoNewMessageSince(r, last);
+  }
+
+  @Test
+  public void removeNotExistingCustomKey() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeMessageInfo last = getLastMessage(r);
+    removeCustomKeys(r, ImmutableSet.of("key1"));
+    assertThatGet(r).isEmpty();
+    assertNoNewMessageSince(r, last);
+
+    addCustomKeyedValues(r, ImmutableMap.of("key1", "value1"));
+    assertThatGet(r).containsExactly("key1", "value1");
+    assertNoNewMessageSince(r, last);
+    removeCustomKeys(r, ImmutableSet.of("key2"));
+    assertThatGet(r).containsExactly("key1", "value1");
+    assertNoNewMessageSince(r, last);
+
+    addCustomKeyedValues(r, ImmutableMap.of("key1", "value1", "key2", "value2", "key3", "value3"));
+    assertThatGet(r).containsExactly("key1", "value1", "key2", "value2", "key3", "value3");
+    assertNoNewMessageSince(r, last);
+    removeCustomKeys(r, ImmutableSet.of("key4"));
+    assertThatGet(r).containsExactly("key1", "value1", "key2", "value2", "key3", "value3");
+    assertNoNewMessageSince(r, last);
+  }
+
+  @Test
+  public void addAndRemove() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeMessageInfo last = getLastMessage(r);
+    addCustomKeyedValues(r, ImmutableMap.of("key1", "value1", "key2", "value2"));
+    assertThatGet(r).containsExactly("key1", "value1", "key2", "value2");
+    assertNoNewMessageSince(r, last);
+
+    // Adding and removing the same key updates it
+    CustomKeyedValuesInput input = new CustomKeyedValuesInput();
+    input.add = ImmutableMap.of("key1", "value3");
+    input.remove = ImmutableSet.of("key1");
+    change(r).setCustomKeyedValues(input);
+    assertThatGet(r).containsExactly("key1", "value3", "key2", "value2");
+    assertNoNewMessageSince(r, last);
+
+    // Adding and removing same key with same value is a no-op.
+    input = new CustomKeyedValuesInput();
+    input.add = ImmutableMap.of("key1", "value3");
+    input.remove = ImmutableSet.of("key1");
+    change(r).setCustomKeyedValues(input);
+    assertThatGet(r).containsExactly("key1", "value3", "key2", "value2");
+    assertNoNewMessageSince(r, last);
+
+    // Adding and removing separate keys should work as expected.
+    input = new CustomKeyedValuesInput();
+    input.add = ImmutableMap.of("key4", "value4");
+    input.remove = ImmutableSet.of("key1");
+    change(r).setCustomKeyedValues(input);
+    assertThatGet(r).containsExactly("key4", "value4", "key2", "value2");
+    assertNoNewMessageSince(r, last);
+  }
+
+  @Test
+  public void addCustomKeyedValuesWithoutPermissionNotAllowed() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> addCustomKeyedValues(r, ImmutableMap.of("key1", "value1")));
+    assertThat(thrown).hasMessageThat().contains("edit custom keyed values not permitted");
+  }
+
+  @Test
+  public void addCustomKeyedValueKeyTooLongNotAllowed() throws Exception {
+    PushOneCommit.Result r = createChange();
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                addCustomKeyedValues(
+                    r, ImmutableMap.of("k".repeat(MAX_CUSTOM_KEY_LENGTH + 1), "value1")));
+    assertThat(thrown).hasMessageThat().contains("Custom Key is too long.");
+  }
+
+  @Test
+  public void addCustomKeyedValueValueTooLongNotAllowed() throws Exception {
+    PushOneCommit.Result r = createChange();
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                addCustomKeyedValues(
+                    r, ImmutableMap.of("key1", "v".repeat(MAX_CUSTOM_KEYED_VALUE_LENGTH + 1))));
+    assertThat(thrown).hasMessageThat().contains("Custom Keyed value is too long.");
+  }
+
+  @Test
+  public void addCustomKeyedValueTooManyKeyedValuesNotAllowed() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ImmutableMap.Builder<String, String> input = ImmutableMap.builder();
+    for (int i = 0; i <= MAX_CUSTOM_KEYED_VALUES; i++) {
+      input.put("key" + i, "value" + i);
+    }
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> addCustomKeyedValues(r, input.build()));
+    assertThat(thrown).hasMessageThat().contains("Too many custom keyed values.");
+  }
+
+  private MapSubject assertThatGet(PushOneCommit.Result r) throws Exception {
+    return assertThat(change(r).getCustomKeyedValues());
+  }
+
+  private void addCustomKeyedValues(PushOneCommit.Result r, ImmutableMap<String, String> toAdd)
+      throws Exception {
+    CustomKeyedValuesInput input = new CustomKeyedValuesInput();
+    input.add = toAdd;
+    change(r).setCustomKeyedValues(input);
+  }
+
+  private void removeCustomKeys(PushOneCommit.Result r, ImmutableSet<String> toRemove)
+      throws Exception {
+    CustomKeyedValuesInput input = new CustomKeyedValuesInput();
+    input.remove = toRemove;
+    change(r).setCustomKeyedValues(input);
+  }
+
+  private void assertNoNewMessageSince(PushOneCommit.Result r, ChangeMessageInfo expected)
+      throws Exception {
+    requireNonNull(expected);
+    ChangeMessageInfo last = getLastMessage(r);
+    assertThat(last.message).isEqualTo(expected.message);
+    assertThat(last.id).isEqualTo(expected.id);
+  }
+
+  private ChangeMessageInfo getLastMessage(PushOneCommit.Result r) throws Exception {
+    ChangeMessageInfo lastMessage = Iterables.getLast(change(r).get().messages, null);
+    assertWithMessage(lastMessage.message).that(lastMessage).isNotNull();
+    return lastMessage;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index c712b14..37684de 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -16,8 +16,11 @@
 
 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.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.util.Comparator.comparing;
 
 import com.google.common.collect.Iterables;
@@ -25,13 +28,20 @@
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.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.FooterConstants;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
@@ -39,6 +49,7 @@
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.server.git.ChangeMessageModifier;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.submit.CommitMergeStatus;
 import com.google.inject.Inject;
 import java.util.List;
@@ -49,6 +60,9 @@
 public class SubmitByCherryPickIT extends AbstractSubmit {
   @Inject private ProjectOperations projectOperations;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private AccountOperations accountOperations;
+  @Inject private ChangeOperations changeOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   @Override
   protected SubmitType getSubmitType() {
@@ -94,6 +108,46 @@
   }
 
   @Test
+  public void submitWithCherryPickAfterUpdatingPreferredEmail() throws Throwable {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Add permissions to apply label "Code-Review": 2 and submit
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(allow(Permission.SUBMIT).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    // Create change to submit
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("content")
+            .owner(testUser)
+            .create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Approve and submit the change
+    RevisionApi revision = gApi.changes().id(changeId.get()).current();
+    revision.review(ReviewInput.approve());
+    revision.submit();
+    assertThat(gApi.changes().id(changeId.get()).get().getCurrentRevision().commit.committer.email)
+        .isEqualTo(emailOne);
+  }
+
+  @Test
   public void changeMessageOnSubmit() throws Throwable {
     PushOneCommit.Result change = createChange();
     ChangeMessageModifier link =
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 0943cfd..b8e5841 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -234,7 +234,7 @@
         "Failed to submit 2 changes due to the following problems:\n"
             + "Change "
             + change2Result.getChange().getId()
-            + ": Depends on change that was not submitted."
+            + ": Depends on commit that cannot be merged."
             + " Commit "
             + change2Result.getCommit().name()
             + " depends on commit "
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index c4f8f2c..ac3622f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -381,8 +381,7 @@
             + " due to the following problems:\n"
             + "Change "
             + change3a.getChange().getId()
-            + ": Depends on change that"
-            + " was not submitted."
+            + ": Depends on commit that cannot be merged."
             + " Commit "
             + change3a.getCommit().name()
             + " depends on commit "
@@ -485,7 +484,7 @@
         "Failed to submit 1 change due to the following problems:\n"
             + "Change "
             + change3.getPatchSetId().changeId().get()
-            + ": Depends on change that was not submitted."
+            + ": Depends on commit that cannot be merged."
             + " Commit "
             + change3.getCommit().name()
             + " depends on commit "
@@ -518,7 +517,7 @@
         "Failed to submit 1 change due to the following problems:\n"
             + "Change "
             + change2Result.getChange().getId()
-            + ": Depends on change that was not submitted."
+            + ": Depends on commit that cannot be merged."
             + " Commit "
             + change2Result.getCommit().name()
             + " depends on commit "
@@ -584,7 +583,7 @@
         "Failed to submit 1 change due to the following problems:\n"
             + "Change "
             + change2Result.getChange().getId()
-            + ": Depends on change that was not submitted."
+            + ": Depends on commit that cannot be merged."
             + " Commit "
             + change2Result.getCommit().name()
             + " depends on commit "
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
index d58ad11..80fbe99 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
@@ -16,7 +16,10 @@
 
 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.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.Comparator.comparing;
 
@@ -27,20 +30,28 @@
 import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.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.FooterConstants;
+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.entities.Permission;
 import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.exceptions.MergeUpdateException;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.ChangeMessageModifier;
+import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import org.eclipse.jgit.lib.ObjectId;
@@ -51,6 +62,9 @@
   @Inject private DynamicItem<UrlFormatter> urlFormatter;
   @Inject private ProjectOperations projectOperations;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private AccountOperations accountOperations;
+  @Inject private ChangeOperations changeOperations;
+  @Inject private RequestScopeOperations requestScopeOperations;
 
   @Override
   protected SubmitType getSubmitType() {
@@ -96,6 +110,46 @@
   }
 
   @Test
+  public void submitByRebaseAfterUpdatingPreferredEmail() throws Throwable {
+    String emailOne = "email1@example.com";
+    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
+
+    // Add permissions to apply label "Code-Review": 2 and submit
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-2, 2))
+        .add(allow(Permission.SUBMIT).ref("refs/heads/master").group(REGISTERED_USERS))
+        .update();
+
+    // Create change to submit
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("content")
+            .owner(testUser)
+            .create();
+
+    // Change preferred email for the user
+    String emailTwo = "email2@example.com";
+    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
+    requestScopeOperations.setApiUser(testUser);
+
+    // Approve and submit the change
+    RevisionApi revision = gApi.changes().id(changeId.get()).current();
+    revision.review(ReviewInput.approve());
+    revision.submit();
+    assertThat(gApi.changes().id(changeId.get()).get().getCurrentRevision().commit.committer.email)
+        .isEqualTo(emailOne);
+  }
+
+  @Test
   public void rebaseInvokesChangeMessageModifiers() throws Throwable {
     ChangeMessageModifier modifier1 =
         (msg, orig, tip, dest) -> msg + "This-change-before-rebase: " + orig.name() + "\n";
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
index d11f1f5..81962e3 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitResolvingMergeCommitIT.java
@@ -312,7 +312,7 @@
         mergeSuperSet
             .get()
             .completeChangeSet(change.change(), user(admin), /* includingTopicClosure= */ false);
-    assertThat(submit.unmergeableChanges(cs).isEmpty()).isEqualTo(expected);
+    assertThat(submit.getUnmergeableChanges(cs).isEmpty()).isEqualTo(expected);
   }
 
   private void assertMergeable(ChangeData change) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java
index 13c20dd..cf3bf89 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetBranchIT.java
@@ -43,8 +43,8 @@
 import com.google.gerrit.extensions.api.projects.TagInput;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.Sequence;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import org.eclipse.jgit.junit.TestRepository;
@@ -458,7 +458,7 @@
   public void getAccountSequenceRef() throws Exception {
     // a user without the 'Access Database' capability cannot see the refs/sequences/accounts ref
     requestScopeOperations.setApiUser(user.id());
-    String accountSequenceRef = RefNames.REFS_SEQUENCES + Sequences.NAME_ACCOUNTS;
+    String accountSequenceRef = RefNames.REFS_SEQUENCES + Sequence.NAME_ACCOUNTS;
     assertBranchNotFound(allUsers, accountSequenceRef);
 
     // a user with the 'Access Database' capability can see the refs/sequences/accounts ref
@@ -469,7 +469,7 @@
   public void getChangeSequenceRef() throws Exception {
     // a user without the 'Access Database' capability cannot see the refs/sequences/changes ref
     requestScopeOperations.setApiUser(user.id());
-    String changeSequenceRef = RefNames.REFS_SEQUENCES + Sequences.NAME_CHANGES;
+    String changeSequenceRef = RefNames.REFS_SEQUENCES + Sequence.NAME_CHANGES;
     assertBranchNotFound(allProjects, changeSequenceRef);
 
     // a user with the 'Access Database' capability can see the refs/sequences/changes ref
@@ -480,7 +480,7 @@
   public void getGroupSequenceRef() throws Exception {
     // a user without the 'Access Database' capability cannot see the refs/sequences/groups ref
     requestScopeOperations.setApiUser(user.id());
-    String groupSequenceRef = RefNames.REFS_SEQUENCES + Sequences.NAME_GROUPS;
+    String groupSequenceRef = RefNames.REFS_SEQUENCES + Sequence.NAME_GROUPS;
     assertBranchNotFound(allUsers, groupSequenceRef);
 
     // a user with the 'Access Database' capability can see the refs/sequences/groups ref
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
index 71ee90c..4c58521 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetProjectIT.java
@@ -29,7 +29,7 @@
 public class GetProjectIT extends AbstractDaemonTest {
 
   @Test
-  public void getProject() throws Exception {
+  public void testGetProject() throws Exception {
     String name = project.get();
     ProjectInfo p = gApi.projects().name(name).get();
     assertThat(p.name).isEqualTo(name);
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
index f8be28b..21a4c98 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance.rest.project;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.rest.project.RefAssert.assertRefs;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -31,13 +32,20 @@
 import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.server.restapi.project.ListBranches;
+import com.google.gerrit.server.restapi.project.ProjectsCollection;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.List;
 import org.junit.Test;
 
 @NoHttpd
 public class ListBranchesIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private Provider<ListBranches> listBranchesProvider;
+  @Inject private ProjectsCollection projects;
 
   @Test
   public void listBranchesOfNonExistingProject_NotFound() throws Exception {
@@ -151,6 +159,185 @@
   }
 
   @Test
+  public void listBranches_withNextPageToken() throws Exception {
+    BranchInfo headBranch = branch("HEAD", "master", false);
+    BranchInfo refsConfig =
+        branch(
+            RefNames.REFS_CONFIG,
+            projectOperations.project(project).getHead(RefNames.REFS_CONFIG).name(),
+            false);
+    BranchInfo masterBranch = createBranch("refs/heads/master", false);
+    BranchInfo branch1 = createBranch("refs/heads/someBranch1", true);
+    BranchInfo branch2 = createBranch("refs/heads/someBranch2", true);
+    BranchInfo branch3 = createBranch("refs/heads/someBranch3", true);
+
+    // Listing all branches returns all 6 branches.
+    assertRefs(
+        ImmutableList.of(headBranch, refsConfig, masterBranch, branch1, branch2, branch3),
+        list().get());
+
+    // No continuation token and limit = 2 returns first two branches.
+    ListBranches listBranches = listBranchesProvider.get();
+    listBranches.setLimit(2);
+    Response<ImmutableList<BranchInfo>> response =
+        listBranches.apply(projects.parse(project.get()));
+    String continuationToken =
+        response.headers().get(ListBranches.NEXT_PAGE_TOKEN_HEADER).asList().get(0);
+    assertRefs(ImmutableList.of(headBranch, refsConfig), response.value());
+    assertThat(continuationToken).isEqualTo(ListBranches.encodeToken("refs/meta/config"));
+
+    // Using the previous continuation token returns the 3rd and 4th branches.
+    listBranches.setNextPageToken(continuationToken);
+    response = listBranches.apply(projects.parse(project.get()));
+    continuationToken = response.headers().get(ListBranches.NEXT_PAGE_TOKEN_HEADER).asList().get(0);
+    assertRefs(ImmutableList.of(masterBranch, branch1), response.value());
+    assertThat(continuationToken).isEqualTo(ListBranches.encodeToken("refs/heads/someBranch1"));
+
+    // Using the previous continuation token returns the 5th and 6th branches. No more continuation
+    // token.
+    listBranches.setNextPageToken(continuationToken);
+    response = listBranches.apply(projects.parse(project.get()));
+    assertRefs(ImmutableList.of(branch2, branch3), response.value());
+    assertThat(response.headers().get(ListBranches.NEXT_PAGE_TOKEN_HEADER)).isEmpty();
+  }
+
+  @Test
+  public void listBranches_withNextPageToken_someBranchesHidden() throws Exception {
+    BranchInfo headBranch = branch("HEAD", "master", false);
+    branch(
+        RefNames.REFS_CONFIG,
+        projectOperations.project(project).getHead(RefNames.REFS_CONFIG).name(),
+        false);
+    BranchInfo masterBranch = createBranch("refs/heads/master", false);
+    BranchInfo branch1 = createBranch("refs/heads/someBranch1", true);
+
+    // Hide refs/meta/config branch.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/meta/config").group(REGISTERED_USERS))
+        .update();
+    // refs/meta/config is not visible.
+    assertRefs(ImmutableList.of(headBranch, masterBranch, branch1), list().get());
+
+    // Try listing branches using the next-page-token
+    ListBranches listBranches = listBranchesProvider.get();
+    listBranches.setLimit(2);
+    Response<ImmutableList<BranchInfo>> response =
+        listBranches.apply(projects.parse(project.get()));
+    String continuationToken =
+        response.headers().get(ListBranches.NEXT_PAGE_TOKEN_HEADER).asList().get(0);
+    assertRefs(ImmutableList.of(headBranch, masterBranch), response.value());
+    assertThat(continuationToken).isEqualTo(ListBranches.encodeToken("refs/heads/master"));
+
+    // Using the previous continuation token returns branch1. The refs/meta/config branch is
+    // skipped.
+    listBranches.setNextPageToken(continuationToken);
+    response = listBranches.apply(projects.parse(project.get()));
+    assertRefs(ImmutableList.of(branch1), response.value());
+    assertThat(response.headers().get(ListBranches.NEXT_PAGE_TOKEN_HEADER)).isEmpty();
+  }
+
+  @Test
+  public void listBranches_withNextPageToken_branchesCreatedAfterFirstPage() throws Exception {
+    BranchInfo headBranch = branch("HEAD", "master", false);
+    BranchInfo refsConfig =
+        branch(
+            RefNames.REFS_CONFIG,
+            projectOperations.project(project).getHead(RefNames.REFS_CONFIG).name(),
+            false);
+    BranchInfo masterBranch = createBranch("refs/heads/master", false);
+    BranchInfo branch2 = createBranch("refs/heads/someBranch2", true);
+    BranchInfo branch3 = createBranch("refs/heads/someBranch3", true);
+    BranchInfo branch4 = createBranch("refs/heads/someBranch4", true);
+
+    // Listing all branches returns all 6 branches. Order is important.
+    assertRefs(
+        ImmutableList.of(headBranch, refsConfig, masterBranch, branch2, branch3, branch4),
+        list().get());
+    ListBranches listBranches = listBranchesProvider.get();
+    listBranches.setLimit(2);
+    Response<ImmutableList<BranchInfo>> response =
+        listBranches.apply(projects.parse(project.get()));
+    String continuationToken =
+        response.headers().get(ListBranches.NEXT_PAGE_TOKEN_HEADER).asList().get(0);
+    assertRefs(ImmutableList.of(headBranch, refsConfig), response.value());
+
+    listBranches.setNextPageToken(continuationToken);
+    response = listBranches.apply(projects.parse(project.get()));
+    continuationToken = response.headers().get(ListBranches.NEXT_PAGE_TOKEN_HEADER).asList().get(0);
+    assertRefs(ImmutableList.of(masterBranch, branch2), response.value());
+
+    // Create Branch1
+    createBranch("refs/heads/someBranch1", false);
+
+    // Using the previous continuation token, branch1 is not returned because the continuation token
+    // points at branch2 (last result of previous response) and will look for results past it.
+    listBranches.setNextPageToken(continuationToken);
+    response = listBranches.apply(projects.parse(project.get()));
+    assertRefs(ImmutableList.of(branch3, branch4), response.value());
+    assertThat(response.headers().get(ListBranches.NEXT_PAGE_TOKEN_HEADER).asList()).isEmpty();
+  }
+
+  @Test
+  public void listBranches_withNonExistentNextPageTokenBranch_startsFromNextGreaterBranch()
+      throws Exception {
+    BranchInfo branch2 = createBranch("refs/heads/someBranch2", true);
+
+    ListBranches listBranches = listBranchesProvider.get();
+    listBranches.setLimit(3);
+    // Set continuation token to a non-existent branch
+    listBranches.setNextPageToken(ListBranches.encodeToken("refs/heads/someBranch1"));
+    Response<ImmutableList<BranchInfo>> response =
+        listBranches.apply(projects.parse(project.get()));
+    List<String> continuationToken =
+        response.headers().get(ListBranches.NEXT_PAGE_TOKEN_HEADER).asList();
+    // Since branch1 does not exist, the server continues from branch2.
+    assertRefs(ImmutableList.of(branch2), response.value());
+    assertThat(continuationToken).isEmpty();
+  }
+
+  @Test
+  public void listBranches_withNextPageTokenGreaterThanAllBranches_returnsEmpty() throws Exception {
+    createBranch("refs/heads/someBranch2", true);
+
+    ListBranches listBranches = listBranchesProvider.get();
+    listBranches.setLimit(3);
+    // Set continuation token to a non-existent branch
+    listBranches.setNextPageToken(ListBranches.encodeToken("refs/heads/someBranch4"));
+    Response<ImmutableList<BranchInfo>> response =
+        listBranches.apply(projects.parse(project.get()));
+    List<String> continuationToken =
+        response.headers().get(ListBranches.NEXT_PAGE_TOKEN_HEADER).asList();
+    // Since branch1 does not exist, the server continues from branch2.
+    assertRefs(ImmutableList.of(), response.value());
+    assertThat(continuationToken).isEmpty();
+  }
+
+  @Test
+  public void listBranches_withBothStartAndNextPageTokenSet_isDisallowed() {
+    Exception exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> list().withStart(2).withNextPageToken("refs/meta/config").get());
+
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("'start' and 'next-page-token' parameters are mutually exclusive.");
+  }
+
+  @Test
+  public void listBranches_withInvalidNextPageToken_isDisallowed() {
+    Exception exception =
+        assertThrows(
+            BadRequestException.class, () -> list().withNextPageToken("invalidToken").get());
+
+    assertThat(exception)
+        .hasMessageThat()
+        .isEqualTo("Invalid 'next-page-token'. This token was not created by the Gerrit server.");
+  }
+
+  @Test
   public void listBranchesUsingFilter() throws Exception {
     BranchInfo master =
         branch("refs/heads/master", pushTo("refs/heads/master").getCommit().getName(), false);
@@ -184,6 +371,10 @@
     return gApi.projects().name(project.get()).branches();
   }
 
+  private BranchInfo createBranch(String name, boolean canDelete) throws Exception {
+    return branch(name, pushTo(name).getCommit().getName(), canDelete);
+  }
+
   private static BranchInfo branch(String ref, String revision, boolean canDelete) {
     BranchInfo info = new BranchInfo();
     info.ref = ref;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
index e69f781..de9b579 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
@@ -43,7 +43,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.project.ProjectCacheImpl;
-import com.google.gerrit.server.restapi.project.ListProjects;
+import com.google.gerrit.server.restapi.project.ListProjectsImpl;
 import com.google.gson.Gson;
 import com.google.gson.JsonObject;
 import com.google.inject.Inject;
@@ -59,7 +59,7 @@
 public class ListProjectsIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
-  @Inject private ListProjects listProjects;
+  @Inject private ListProjectsImpl listProjects;
 
   @Test
   public void listProjects() throws Exception {
@@ -140,6 +140,33 @@
   }
 
   @Test
+  @GerritConfig(name = "gerrit.listProjectsFromIndex", value = "true")
+  public void listProjectsSetsMoreProjectsIfLimited_indexEnabled() throws Exception {
+    testListProjectsSetsMoreProjectsIfLimited();
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.listProjectsFromIndex", value = "false")
+  public void listProjectsSetsMoreProjectsIfLimited_indexDisabled() throws Exception {
+    testListProjectsSetsMoreProjectsIfLimited();
+  }
+
+  private void testListProjectsSetsMoreProjectsIfLimited() throws Exception {
+    for (int i = 0; i < 3; i++) {
+      projectOperations.newProject().name("prefix-" + i).create();
+    }
+
+    List<ProjectInfo> result = gApi.projects().list().withPrefix("prefix").get();
+    assertThat(Iterables.getLast(result)._moreProjects).isNull();
+
+    result = gApi.projects().list().withPrefix("prefix").withLimit(Integer.MAX_VALUE).get();
+    assertThat(Iterables.getLast(result)._moreProjects).isNull();
+
+    result = gApi.projects().list().withPrefix("prefix").withLimit(2).get();
+    assertThat(Iterables.getLast(result)._moreProjects).isTrue();
+  }
+
+  @Test
   public void listProjectsToOutputStream() throws Exception {
     int numInitialProjects = gApi.projects().list().get().size();
     int numTestProjects = 5;
@@ -159,7 +186,8 @@
 
   @Test
   public void listProjectsAsJsonMultilineToOutputStream() throws Exception {
-    listProjectsAsJsonToOutputStream(OutputFormat.JSON);
+    String jsonOutput = listProjectsAsJsonToOutputStream(OutputFormat.JSON);
+    assertThat(jsonOutput).contains("\n");
   }
 
   @Test
@@ -198,7 +226,18 @@
   }
 
   @Test
-  public void listProjectsWithPrefix() throws Exception {
+  @GerritConfig(name = "gerrit.listProjectsFromIndex", value = "true")
+  public void listProjectsWithPrefix_indexEnabled() throws Exception {
+    testListProjectsWithPrefix();
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.listProjectsFromIndex", value = "false")
+  public void listProjectsWithPrefix_indexDisabled() throws Exception {
+    testListProjectsWithPrefix();
+  }
+
+  private void testListProjectsWithPrefix() throws Exception {
     Project.NameKey someProject = projectOperations.newProject().name("listtest-p1").create();
     Project.NameKey someOtherProject = projectOperations.newProject().name("listtest-p2").create();
     projectOperations.newProject().name("other-prefix-project").create();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/SuggestBranchReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/project/SuggestBranchReviewersIT.java
new file mode 100644
index 0000000..98f5716
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/SuggestBranchReviewersIT.java
@@ -0,0 +1,165 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.config.GerritConfig;
+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.data.GlobalCapability;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
+import com.google.inject.Inject;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+
+public class SuggestBranchReviewersIT extends AbstractDaemonTest {
+
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Inject private ProjectOperations projectOperations;
+
+  @Inject private GroupOperations groupOperations;
+
+  private TestAccount user1;
+
+  private TestAccount user2;
+  private TestAccount user3;
+
+  private AccountGroup.UUID group1;
+
+  private TestAccount user(String name, String fullName, String emailName) throws Exception {
+    return accountCreator.create(name(name), name(emailName) + "@example.com", fullName, null);
+  }
+
+  private TestAccount user(String name, String fullName) throws Exception {
+    return user(name, fullName, name);
+  }
+
+  @Before
+  public void setUp() throws Exception {
+    gApi.projects().name(project.get()).branch("otherBranch").create(new BranchInput());
+    user1 = user("user1", "First1 Last1");
+    user2 = user("user2", "First2 Last2");
+    user3 = user("user3", "First3 Last3");
+    group1 =
+        groupOperations.newGroup().name(name("users1")).members(user1.id(), user3.id()).create();
+  }
+
+  private static void assertReviewers(
+      List<SuggestedReviewerInfo> actual,
+      List<TestAccount> expectedUsers,
+      List<AccountGroup.UUID> expectedGroups) {
+    List<Integer> actualAccountIds =
+        actual.stream()
+            .filter(i -> i.account != null)
+            .map(i -> i.account._accountId)
+            .collect(toList());
+    assertThat(actualAccountIds)
+        .containsExactlyElementsIn(expectedUsers.stream().map(u -> u.id().get()).collect(toList()));
+
+    List<String> actualGroupIds =
+        actual.stream().filter(i -> i.group != null).map(i -> i.group.id).collect(toList());
+    assertThat(actualGroupIds)
+        .containsExactlyElementsIn(
+            expectedGroups.stream().map(AccountGroup.UUID::get).collect(toList()))
+        .inOrder();
+  }
+
+  private List<SuggestedReviewerInfo> suggestReviewers(String query) throws Exception {
+    return gApi.projects().name(project.get()).branch("otherBranch").suggestReviewers(query).get();
+  }
+
+  private List<SuggestedReviewerInfo> suggestReviewers(String query, int n) throws Exception {
+    return gApi.projects()
+        .name(project.get())
+        .branch("otherBranch")
+        .suggestReviewers(query)
+        .withLimit(n)
+        .get();
+  }
+
+  private List<SuggestedReviewerInfo> suggestCcs(String query) throws Exception {
+    return gApi.projects().name(project.get()).branch("otherBranch").suggestCcs(query).get();
+  }
+
+  @Test
+  public void suggestReviewerAsCc() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+
+    String name = name("foo");
+    TestAccount foo1 = accountCreator.create(name + "-1");
+
+    TestAccount foo2 = accountCreator.create(name + "-2");
+
+    assertReviewers(suggestCcs(name), ImmutableList.of(foo1, foo2), ImmutableList.of());
+  }
+
+  @Test
+  public void suggestReviewersWithoutLimitOptionSpecified() throws Exception {
+    String query = user3.username();
+    List<SuggestedReviewerInfo> suggestedReviewers = suggestReviewers(query);
+    assertThat(suggestedReviewers).hasSize(1);
+  }
+
+  @Test
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  public void suggestReviewersViewAllAccounts() throws Exception {
+    List<SuggestedReviewerInfo> reviewers;
+
+    requestScopeOperations.setApiUser(user1.id());
+    reviewers = suggestReviewers(user2.username(), 2);
+    assertThat(reviewers).isEmpty();
+
+    // Clear cached group info.
+    requestScopeOperations.setApiUser(user1.id());
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.VIEW_ALL_ACCOUNTS).group(group1))
+        .update();
+    reviewers = suggestReviewers(user2.username(), 2);
+    assertThat(reviewers).hasSize(1);
+    assertThat(Iterables.getOnlyElement(reviewers).account.name).isEqualTo(user2.fullName());
+  }
+
+  @Test
+  public void suggestNoInactiveAccounts() throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+
+    String name = name("foo");
+    TestAccount foo1 = accountCreator.create(name + "-1");
+    assertThat(gApi.accounts().id(foo1.username()).getActive()).isTrue();
+
+    TestAccount foo2 = accountCreator.create(name + "-2");
+    assertThat(gApi.accounts().id(foo2.username()).getActive()).isTrue();
+
+    assertReviewers(suggestReviewers(name), ImmutableList.of(foo1, foo2), ImmutableList.of());
+
+    requestScopeOperations.setApiUser(user.id());
+    gApi.accounts().id(foo2.username()).setActive(false);
+    assertThat(gApi.accounts().id(foo2.id().get()).getActive()).isFalse();
+    assertReviewers(suggestReviewers(name), ImmutableList.of(foo1), ImmutableList.of());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/util/RestApiCallHelper.java b/javatests/com/google/gerrit/acceptance/rest/util/RestApiCallHelper.java
index 55735fc..3eb6eb2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/util/RestApiCallHelper.java
+++ b/javatests/com/google/gerrit/acceptance/rest/util/RestApiCallHelper.java
@@ -19,6 +19,7 @@
 import static org.apache.http.HttpStatus.SC_INTERNAL_SERVER_ERROR;
 import static org.apache.http.HttpStatus.SC_METHOD_NOT_ALLOWED;
 import static org.apache.http.HttpStatus.SC_NOT_FOUND;
+import static org.apache.http.HttpStatus.SC_UNAUTHORIZED;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.RestResponse;
@@ -92,7 +93,8 @@
     } else {
       assertWithMessage(msg)
           .that(status)
-          .isNotIn(ImmutableList.of(SC_FORBIDDEN, SC_NOT_FOUND, SC_METHOD_NOT_ALLOWED));
+          .isNotIn(
+              ImmutableList.of(SC_UNAUTHORIZED, SC_FORBIDDEN, SC_NOT_FOUND, SC_METHOD_NOT_ALLOWED));
       assertWithMessage(msg).that(status).isLessThan(SC_INTERNAL_SERVER_ERROR);
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
index 35ecceb..b150491 100644
--- a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountResolver;
@@ -39,7 +40,6 @@
 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.notedb.Sequences;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
diff --git a/javatests/com/google/gerrit/acceptance/server/account/externalids/OnlineExternalIdCaseSensivityMigratorIT.java b/javatests/com/google/gerrit/acceptance/server/account/externalids/OnlineExternalIdCaseSensivityMigratorIT.java
index 4eac16f..ee00e40 100644
--- a/javatests/com/google/gerrit/acceptance/server/account/externalids/OnlineExternalIdCaseSensivityMigratorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/account/externalids/OnlineExternalIdCaseSensivityMigratorIT.java
@@ -25,12 +25,12 @@
 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.account.externalids.storage.notedb.ExternalIdCaseSensitivityMigrator;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNotes;
+import com.google.gerrit.server.account.externalids.storage.notedb.OnlineExternalIdCaseSensivityMigrator;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 366789a..92a786a 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -29,6 +29,7 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
@@ -54,6 +55,7 @@
 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.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -89,6 +91,8 @@
 @NoHttpd
 public class CommentsIT extends AbstractDaemonTest {
   @Inject private ChangeNoteUtil noteUtil;
+
+  @Inject private ExtensionRegistry extensionRegistry;
   @Inject private FakeEmailSender email;
   @Inject private ProjectOperations projectOperations;
   @Inject private Provider<ChangesCollection> changes;
@@ -97,6 +101,21 @@
   @Inject private AccountOperations accountOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
+  private static class TestGitReferenceUpdatedListener implements GitReferenceUpdatedListener {
+
+    private GitReferenceUpdatedListener.Event lastReferenceUpdatedEvent;
+
+    @Override
+    public void onGitReferenceUpdated(GitReferenceUpdatedListener.Event event) {
+      lastReferenceUpdatedEvent = event;
+    }
+
+    public GitReferenceUpdatedListener.Event getLastReferenceUpdatedEvent() {
+      assertThat(lastReferenceUpdatedEvent).isNotNull();
+      return lastReferenceUpdatedEvent;
+    }
+  }
+
   private final Integer[] lines = {0, 1};
 
   @Before
@@ -136,6 +155,38 @@
   }
 
   @Test
+  public void fireEventsForOperationsOnDrafts() throws Exception {
+    TestGitReferenceUpdatedListener listener = new TestGitReferenceUpdatedListener();
+    requestScopeOperations.setApiUser(user.id());
+    try (ExtensionRegistry.Registration registration =
+        extensionRegistry.newRegistration().add(listener)) {
+      PushOneCommit.Result r = createChange();
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      String path = "file1";
+      DraftInput comment =
+          CommentsUtil.newDraftWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment 1");
+      CommentInfo ci = addDraft(changeId, revId, comment);
+
+      List<CommentInfo> list = getDraftCommentsAsList(changeId);
+      assertThat(list).hasSize(1);
+
+      assertDraftAddedEvent(listener);
+
+      Map<String, List<CommentInfo>> results = getDraftComments(changeId, revId);
+      DraftInput update = CommentsUtil.newDraft(path, Side.REVISION, 1, "comment 2");
+      update.id = Iterables.getOnlyElement(results.get(PATCHSET_LEVEL)).id;
+      update.line = 1;
+
+      updateDraft(changeId, revId, update, ci.id);
+      assertDraftUpdatedEvent(listener);
+
+      deleteDraft(changeId, revId, ci.id);
+      assertDraftDeletedEvent(listener);
+    }
+  }
+
+  @Test
   public void createDraftOnMergeCommitChange() throws Exception {
     for (Integer line : lines) {
       PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
@@ -632,6 +683,51 @@
   }
 
   @Test
+  public void updateAndPublishDraftCommentOnOldPatchSet() throws Exception {
+    // create a change
+    String file = "file";
+    PushOneCommit push =
+        pushFactory.create(admin.newIdent(), testRepo, "first subject", "file", "l1\nl2\n");
+    String dest = "refs/for/master";
+    PushOneCommit.Result r1 = push.to(dest);
+    r1.assertOkStatus();
+    String changeId = r1.getChangeId();
+    String revId = r1.getCommit().getName();
+
+    // create a second patch set
+    PushOneCommit.Result r2 = amendChange(r1.getChangeId());
+    r2.assertOkStatus();
+
+    // create a draft comment on the first patch set
+    String draftRefName = RefNames.refsDraftComments(r1.getChange().getId(), admin.id());
+    DraftInput draft = CommentsUtil.newDraft(file, Side.REVISION, 1, "comment");
+    CommentInfo draftInfo = addDraft(changeId, "1", draft);
+
+    // update the draft comment and publish it at the same time via PUBLISH_ALL_REVISIONS
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.message = "bar";
+    CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, 1, "comment", false);
+    comment.id = draftInfo.id;
+    reviewInput.comments = new HashMap<>();
+    reviewInput.comments.put(comment.path, ImmutableList.of(comment));
+    reviewInput.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
+    gApi.changes().id(r1.getChangeId()).revision(2).review(reviewInput);
+
+    // check that the draft comment is no longer present
+    Map<String, List<CommentInfo>> drafts = getDraftComments(changeId, revId);
+    assertThat(drafts.isEmpty()).isTrue();
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      Ref ref = repo.exactRef(draftRefName);
+      assertThat(ref).isNull();
+    }
+
+    // check that the draft comment is published on the old patch set now
+    CommentInfo publishedComment = Iterables.getOnlyElement(getPublishedCommentsAsList(changeId));
+    assertThat(publishedComment.id).isEqualTo(draftInfo.id);
+    assertThat(publishedComment.patchSet).isEqualTo(1);
+  }
+
+  @Test
   public void listComments() throws Exception {
     String file = "file";
     PushOneCommit push =
@@ -1262,6 +1358,7 @@
                 + c
                 + "/comment/"
                 + ps1List.get(0).id
+                + "?usp=email"
                 + " :\n"
                 + "PS1, Line 1: initial\n"
                 + "what happened to this?\n"
@@ -1274,6 +1371,7 @@
                 + c
                 + "/comment/"
                 + ps1List.get(1).id
+                + "?usp=email"
                 + " :\n"
                 + "PS1, Line 1: boring\n"
                 + "Is it that bad?\n"
@@ -1288,6 +1386,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(0).id
+                + "?usp=email"
                 + " :\n"
                 + "PS2, Line 1: initial content\n"
                 + "comment 1 on base\n"
@@ -1300,6 +1399,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(1).id
+                + "?usp=email"
                 + " :\n"
                 + "PS2, Line 2: \n"
                 + "comment 2 on base\n"
@@ -1312,6 +1412,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(2).id
+                + "?usp=email"
                 + " :\n"
                 + "PS2, Line 1: interesting\n"
                 + "better now\n"
@@ -1324,6 +1425,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(3).id
+                + "?usp=email"
                 + " :\n"
                 + "PS2, Line 2: cntent\n"
                 + "typo: content\n"
@@ -2162,4 +2264,31 @@
     in.subject = "New changes";
     return gApi.changes().create(in).get();
   }
+
+  private GitReferenceUpdatedListener.Event assertDraftEvent(
+      TestGitReferenceUpdatedListener listener) {
+    GitReferenceUpdatedListener.Event event = listener.getLastReferenceUpdatedEvent();
+    assertThat(event.getRefName()).startsWith("refs/draft-comments/");
+    assertThat(event.getProjectName()).isEqualTo(allUsers.get());
+    assertThat(event.getUpdater()._accountId).isEqualTo(user.id().get());
+    return event;
+  }
+
+  private void assertDraftAddedEvent(TestGitReferenceUpdatedListener listener) {
+    GitReferenceUpdatedListener.Event event = assertDraftEvent(listener);
+    assertThat(event.isCreate()).isTrue();
+    assertThat(event.isDelete()).isFalse();
+  }
+
+  private void assertDraftUpdatedEvent(TestGitReferenceUpdatedListener listener) {
+    GitReferenceUpdatedListener.Event event = assertDraftEvent(listener);
+    assertThat(event.isCreate()).isFalse();
+    assertThat(event.isDelete()).isFalse();
+  }
+
+  private void assertDraftDeletedEvent(TestGitReferenceUpdatedListener listener) {
+    GitReferenceUpdatedListener.Event event = assertDraftEvent(listener);
+    assertThat(event.isCreate()).isFalse();
+    assertThat(event.isDelete()).isTrue();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index 55f102f..07e4866 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -38,13 +38,13 @@
 import com.google.gerrit.extensions.common.ProblemInfo;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ConsistencyChecker;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
diff --git a/javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java b/javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java
index 107b777..97024f2 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java
@@ -100,13 +100,16 @@
     // 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);
+    worker.setup();
+    assertThat(worker.listDraftCommentsThatAreAlsoPublished()).hasSize(1);
+    int deletedDrafts = worker.execute();
     if (dryRun) {
       assertThat(getDraftsByParsingDraftRef(draftRef.getName(), revId)).hasSize(1);
     } else {
       assertThat(getDraftsByParsingDraftRef(draftRef.getName(), revId)).isEmpty();
     }
     assertNumPublishedComments(changeId, 1);
+    assertThat(deletedDrafts).isEqualTo(1);
   }
 
   @Test
@@ -136,7 +139,9 @@
     // 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);
+    worker.setup();
+    assertThat(worker.listDraftCommentsThatAreAlsoPublished()).hasSize(1);
+    int deletedDrafts = worker.execute();
     assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r1.getCommit().name())).hasSize(1);
     if (dryRun) {
       assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r2.getCommit().name())).hasSize(1);
@@ -144,10 +149,13 @@
       assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r2.getCommit().name())).isEmpty();
     }
     assertNumPublishedComments(changeId, 1);
+    assertThat(deletedDrafts).isEqualTo(1);
 
     // Re-run the worker: nothing happens.
-    assertThat(worker.deleteDraftCommentsThatAreAlsoPublished()).isEqualTo(dryRun ? 1 : 0);
+    assertThat(worker.listDraftCommentsThatAreAlsoPublished()).hasSize(dryRun ? 1 : 0);
+    deletedDrafts = worker.execute();
     assertNumDrafts(changeId, 1);
+    assertThat(deletedDrafts).isEqualTo(dryRun ? 1 : 0);
     assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r1.getCommit().name())).hasSize(1);
     if (dryRun) {
       assertThat(getDraftsByParsingDraftRef(draftRef.getName(), r2.getCommit().name())).hasSize(1);
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index a1ba293..7eec5ea 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -20,7 +20,6 @@
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
 import static com.google.gerrit.extensions.common.testing.EditInfoSubject.assertThat;
-import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
@@ -52,11 +51,6 @@
 import com.google.gerrit.server.change.GetRelatedChangesUtil;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.context.RefUpdateContext;
-import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
 import java.util.ArrayList;
@@ -541,38 +535,6 @@
   }
 
   @Test
-  public void pushNewPatchSetWhenParentHasNullGroup() throws Exception {
-    // 1,1---2,1
-    //   \---2,2
-
-    RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
-    RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id psId1_1 = getPatchSetId(c1_1);
-    PatchSet.Id psId2_1 = getPatchSetId(c2_1);
-
-    for (PatchSet.Id psId : ImmutableList.of(psId1_1, psId2_1)) {
-      assertRelated(psId, changeAndCommit(psId2_1, c2_1, 1), changeAndCommit(psId1_1, c1_1, 1));
-    }
-
-    // Pretend PS1,1 was pushed before the groups field was added.
-    clearGroups(psId1_1);
-    indexer.index(changeDataFactory.create(project, psId1_1.changeId()));
-
-    // PS1,1 has no groups, so disappeared from related changes.
-    assertRelated(psId2_1);
-
-    RevCommit c2_2 = testRepo.amend(c2_1).add("c.txt", "2").create();
-    testRepo.reset(c2_2);
-    pushHead(testRepo, "refs/for/master", false);
-    PatchSet.Id psId2_2 = getPatchSetId(c2_2);
-
-    // Push updated the group for PS1,1, so it shows up in related changes even
-    // though a new patch set was not pushed.
-    assertRelated(psId2_2, changeAndCommit(psId2_2, c2_2, 2), changeAndCommit(psId1_1, c1_1, 1));
-  }
-
-  @Test
   @GerritConfig(name = "index.autoReindexIfStale", value = "false")
   public void getRelatedForStaleChange() throws Exception {
     RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
@@ -734,23 +696,6 @@
     return result;
   }
 
-  private void clearGroups(PatchSet.Id psId) throws Exception {
-    try (RefUpdateContext ctx = openTestRefUpdateContext()) {
-      try (BatchUpdate bu = batchUpdateFactory.create(project, user(user), TimeUtil.now())) {
-        bu.addOp(
-            psId.changeId(),
-            new BatchUpdateOp() {
-              @Override
-              public boolean updateChange(ChangeContext ctx) {
-                ctx.getUpdate(psId).setGroups(ImmutableList.of());
-                return true;
-              }
-            });
-        bu.execute();
-      }
-    }
-  }
-
   private void assertRelated(PatchSet.Id psId, RelatedChangeAndCommitInfo... expected)
       throws Exception {
     assertRelated(psId, Arrays.asList(expected));
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 13e2f24..83a0153 100644
--- a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.server.git.receive;
 
 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.Mockito.clearInvocations;
 import static org.mockito.Mockito.mock;
@@ -36,9 +37,13 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentForValidation.CommentSource;
+import com.google.gerrit.extensions.validators.CommentForValidation.CommentType;
 import com.google.gerrit.extensions.validators.CommentValidationContext;
 import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.server.update.CommentsRejectedException;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gerrit.testing.TestCommentHelper;
 import com.google.inject.Inject;
@@ -105,7 +110,9 @@
     amendResult.assertNotMessage("Comment validation failure:");
     assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).hasSize(1);
 
-    assertThat(captureCtx.getAllValues()).hasSize(1);
+    // Comment were validated twice: first when the draft was created, and second when it was
+    // published.
+    assertThat(captureCtx.getAllValues()).hasSize(2);
     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");
@@ -191,12 +198,10 @@
             ImmutableList.of(COMMENT_FOR_VALIDATION)))
         .thenReturn(ImmutableList.of(COMMENT_FOR_VALIDATION.failValidation("Oh no!")));
     DraftInput comment = testCommentHelper.newDraft(COMMENT_TEXT);
-    testCommentHelper.addDraft(changeId, revId, comment);
-    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).isEmpty();
-    Result amendResult = amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
-    amendResult.assertOkStatus();
-    amendResult.assertMessage("Comment validation failure:");
-    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).isEmpty();
+    Exception thrown =
+        assertThrows(
+            BadRequestException.class, () -> testCommentHelper.addDraft(changeId, revId, comment));
+    assertThat(thrown).hasCauseThat().isInstanceOf(CommentsRejectedException.class);
   }
 
   @Test
@@ -216,24 +221,34 @@
     amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
     assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).hasSize(2);
 
-    assertThat(capture.getAllValues()).hasSize(1);
+    // Validation was called 3 times: once for each draft, and once when both drafts were published
+    assertThat(capture.getAllValues()).hasSize(3);
 
     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(
-            CommentForValidation.create(
-                CommentForValidation.CommentSource.HUMAN,
-                CommentForValidation.CommentType.INLINE_COMMENT,
-                draftInline.message,
-                draftInline.message.length()),
-            CommentForValidation.create(
-                CommentForValidation.CommentSource.HUMAN,
-                CommentForValidation.CommentType.FILE_COMMENT,
-                draftFile.message,
-                draftFile.message.length()));
+    CommentForValidation firstValidatedDraft =
+        CommentForValidation.create(
+            CommentSource.HUMAN,
+            CommentType.FILE_COMMENT,
+            draftFile.message,
+            draftFile.message.length());
+
+    CommentForValidation secondValidatedDraft =
+        CommentForValidation.create(
+            CommentSource.HUMAN,
+            CommentType.INLINE_COMMENT,
+            draftInline.message,
+            draftInline.message.length());
+
+    // First invocation validated first draft on creation
+    assertThat(capture.getAllValues().get(0)).containsExactly(firstValidatedDraft);
+    // Second invocation validated second draft on creation
+    assertThat(capture.getAllValues().get(1)).containsExactly(secondValidatedDraft);
+    // THird invocation validated both drafts when they got published
+    assertThat(capture.getAllValues().get(2))
+        .containsExactly(firstValidatedDraft, secondValidatedDraft);
   }
 
   @Test
@@ -245,14 +260,17 @@
     int commentLength = COMMENT_SIZE_LIMIT + 1;
     DraftInput comment =
         testCommentHelper.newDraft(new String(new char[commentLength]).replace("\0", "x"));
-    testCommentHelper.addDraft(changeId, result.getCommit().getName(), comment);
-
-    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).isEmpty();
-    Result amendResult = amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
-    amendResult.assertOkStatus();
-    amendResult.assertMessage(
-        String.format("Comment size exceeds limit (%d > %d)", commentLength, COMMENT_SIZE_LIMIT));
-    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).isEmpty();
+    Exception thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> testCommentHelper.addDraft(changeId, result.getCommit().getName(), comment));
+    assertThat(thrown).hasCauseThat().isInstanceOf(CommentsRejectedException.class);
+    assertThat(thrown)
+        .hasCauseThat()
+        .hasMessageThat()
+        .contains(
+            String.format(
+                "Comment size exceeds limit (%d > %d)", commentLength, COMMENT_SIZE_LIMIT));
   }
 
   @Test
@@ -278,13 +296,15 @@
     }
     // We now have 1 comment, 2 robot comments, 5 change messages.
 
-    draftInline = testCommentHelper.newDraft(filePath, Side.REVISION, 1, COMMENT_TEXT);
-    testCommentHelper.addDraft(changeId, revId, draftInline);
-    // Publishes the 1 draft and adds 2 change messages. The latter 2 are autogenerated and are not
-    // subject to validation.
-    Result amendResult = amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
-    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).hasSize(1);
-    amendResult.assertMessage("exceeding maximum number of comments");
+    DraftInput newDraft = testCommentHelper.newDraft(filePath, Side.REVISION, 1, COMMENT_TEXT);
+    Exception thrown =
+        assertThrows(
+            BadRequestException.class, () -> testCommentHelper.addDraft(changeId, revId, newDraft));
+    assertThat(thrown).hasCauseThat().isInstanceOf(CommentsRejectedException.class);
+    assertThat(thrown)
+        .hasCauseThat()
+        .hasMessageThat()
+        .contains("Exceeding maximum number of comments");
   }
 
   @Test
@@ -302,10 +322,16 @@
     amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
     assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).hasSize(1);
 
-    draftInline = testCommentHelper.newDraft(filePath, Side.REVISION, 1, commentText400Bytes);
-    testCommentHelper.addDraft(changeId, revId, draftInline);
-    Result amendResult = amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
-    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).hasSize(1);
-    amendResult.assertMessage("exceeding maximum cumulative size of comments");
+    DraftInput invalidDraft =
+        testCommentHelper.newDraft(filePath, Side.REVISION, 1, commentText400Bytes);
+    Exception thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> testCommentHelper.addDraft(changeId, revId, invalidDraft));
+    assertThat(thrown).hasCauseThat().isInstanceOf(CommentsRejectedException.class);
+    assertThat(thrown)
+        .hasCauseThat()
+        .hasMessageThat()
+        .contains("Exceeding maximum cumulative size of comments");
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsLimitsIT.java b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsLimitsIT.java
index 89b5f6e..eab0d39 100644
--- a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsLimitsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsLimitsIT.java
@@ -83,27 +83,18 @@
             .create();
     testRepo.reset(commitFoo);
 
-    // By convention we diff against the first parent.
-
-    // commitFoo is first -> 1 file changed -> OK
+    // compared to AUTO_MERGE only one file is changed -> OK
     pushFactory
         .create(
-            admin.newIdent(),
-            testRepo,
-            "blah",
-            ImmutableMap.of("foo.txt", "same old, same old", "bar.txt", "changed file"))
+            admin.newIdent(), testRepo, "blah", ImmutableMap.of("foo.txt", "same old, same old"))
         .setParents(ImmutableList.of(commitFoo, commitBar))
         .to("refs/for/master")
         .assertOkStatus();
 
-    // commitBar is first -> 2 files changed -> rejected
+    // compared to AUTO_MERGE two files are changed -> rejected
     pushFactory
-        .create(
-            admin.newIdent(),
-            testRepo,
-            "blah",
-            ImmutableMap.of("foo.txt", "same old, same old", "bar.txt", "changed file"))
-        .setParents(ImmutableList.of(commitBar, commitFoo))
+        .create(admin.newIdent(), testRepo, "blah", ImmutableMap.of("foo.txt", "changed"))
+        .setParents(ImmutableList.of(commitFoo, commitBar))
         .to("refs/for/master")
         .assertErrorStatus("Exceeding maximum number of files per change (2 > 1)");
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index ea836e6..b606271 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -297,7 +297,6 @@
     assertThat(sender)
         .sent("newchange", sc)
         .to(reviewer)
-        .cc(sc.reviewer)
         .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .noOneElse();
     assertThat(sender).didNotSend();
@@ -328,7 +327,7 @@
     addReviewer(singly(), sc.changeId, sc.owner, reviewer.email());
 
     // No BY_EMAIL cc's.
-    assertThat(sender).sent("newchange", sc).to(reviewer).cc(sc.reviewer).noOneElse();
+    assertThat(sender).sent("newchange", sc).to(reviewer).noOneElse();
     assertThat(sender).didNotSend();
   }
 
@@ -340,7 +339,7 @@
     assertThat(sender)
         .sent("newchange", sc)
         .to(reviewer)
-        .cc(sc.owner, sc.reviewer)
+        .cc(sc.owner)
         .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .noOneElse();
     assertThat(sender).didNotSend();
@@ -365,7 +364,6 @@
     assertThat(sender)
         .sent("newchange", sc)
         .to(reviewer)
-        .cc(sc.owner, sc.reviewer)
         .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .noOneElse();
     assertThat(sender).didNotSend();
@@ -390,7 +388,7 @@
     assertThat(sender)
         .sent("newchange", sc)
         .to(reviewer)
-        .cc(sc.owner, sc.reviewer, other)
+        .cc(other)
         .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .noOneElse();
     assertThat(sender).didNotSend();
@@ -414,7 +412,6 @@
     assertThat(sender)
         .sent("newchange", sc)
         .to(email)
-        .cc(sc.reviewer)
         .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .noOneElse();
     assertThat(sender).didNotSend();
@@ -470,7 +467,6 @@
     assertThat(sender)
         .sent("newchange", sc)
         .to(reviewer)
-        .cc(sc.reviewer)
         .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .noOneElse();
   }
@@ -483,7 +479,6 @@
     assertThat(sender)
         .sent("newchange", sc)
         .to(reviewer)
-        .cc(sc.reviewer)
         .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .noOneElse();
     assertThat(sender).didNotSend();
@@ -507,7 +502,6 @@
     assertThat(sender)
         .sent("newchange", sc)
         .to(reviewer)
-        .cc(sc.reviewer)
         .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .noOneElse();
     assertThat(sender).didNotSend();
@@ -565,7 +559,6 @@
     assertThat(sender)
         .sent("newchange", sc)
         .to("nonexistent@example.com")
-        .cc(sc.reviewer)
         .cc(StagedUsers.CC_BY_EMAIL, StagedUsers.REVIEWER_BY_EMAIL)
         .noOneElse();
     assertThat(sender).didNotSend();
@@ -587,7 +580,6 @@
     assertThat(sender)
         .sent("newchange", sc)
         .cc("nonexistent@example.com")
-        .cc(sc.reviewer)
         .cc(StagedUsers.CC_BY_EMAIL, StagedUsers.REVIEWER_BY_EMAIL)
         .noOneElse();
     assertThat(sender).didNotSend();
@@ -1034,7 +1026,6 @@
     assertThat(sender)
         .sent("newchange", sc)
         .to(other)
-        .cc(sc.reviewer)
         .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
         .noOneElse();
     assertThat(sender).didNotSend();
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
index f728995..5e00230 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
@@ -62,7 +62,7 @@
     Map<String, EmailHeader> headers = sender.getMessages().iterator().next().headers();
     String hostname = URI.create(canonicalWebUrl.get()).getHost();
     String listId = String.format("<gerrit-%s.%s>", project.get(), hostname);
-    String unsubscribeLink = String.format("<%ssettings>", canonicalWebUrl.get());
+    String unsubscribeLink = String.format("<%ssettings?usp=email>", canonicalWebUrl.get());
     String threadId =
         String.format(
             "<gerrit.%s.%s@%s>",
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/ExternalIdNotesUpsertPreprocessorIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/ExternalIdNotesUpsertPreprocessorIT.java
index 1b164eb..4529f72 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/ExternalIdNotesUpsertPreprocessorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/ExternalIdNotesUpsertPreprocessorIT.java
@@ -23,14 +23,14 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
-import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIdUpsertPreprocessor;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNotes;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
index 6e67d5f..0fb6b9e 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.server.project.ProjectCacheImpl;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.name.Named;
+import java.util.Optional;
 import javax.inject.Inject;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
@@ -39,7 +40,7 @@
 
   @Inject
   @Named(ProjectCacheImpl.CACHE_NAME)
-  private LoadingCache<Project.NameKey, CachedProjectConfig> inMemoryProjectCache;
+  private LoadingCache<Project.NameKey, Optional<CachedProjectConfig>> inMemoryProjectCache;
 
   @Inject private SitePaths sitePaths;
 
@@ -87,6 +88,35 @@
   }
 
   @Test
+  public void pluginConfig_inheritanceCanOverrideValuesAndKeepsRest() throws Exception {
+    try (AbstractDaemonTest.ProjectConfigUpdate u = updateProject(allProjects)) {
+      u.getConfig()
+          .updatePluginConfig(
+              "important-plugin2",
+              cfg -> {
+                cfg.setString("key", "kept");
+                cfg.setString("key2", "my-plugin-value2");
+              });
+      u.save();
+    }
+
+    try (AbstractDaemonTest.ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updatePluginConfig(
+              "important-plugin2",
+              cfg -> {
+                cfg.setString("key2", "overridden");
+              });
+      u.save();
+    }
+
+    PluginConfig pluginConfig =
+        pluginConfigFactory.getFromProjectConfigWithInheritance(project, "important-plugin2");
+    assertThat(pluginConfig.getString("key")).isEqualTo("kept");
+    assertThat(pluginConfig.getString("key2")).isEqualTo("overridden");
+  }
+
+  @Test
   public void allProjectsProjectsConfig_ChangeInFileInvalidatesPersistedCache() throws Exception {
     assertThat(projectCache.getAllProjects().getConfig().getCheckReceivedObjects()).isTrue();
     // Change etc/All-Projects-project.config
@@ -104,4 +134,26 @@
     inMemoryProjectCache.invalidate(allProjects);
     assertThat(projectCache.getAllProjects().getConfig().getCheckReceivedObjects()).isFalse();
   }
+
+  @Test
+  public void cachesNegativeLookup() throws Exception {
+    long initialNumMisses = inMemoryProjectCache.stats().missCount();
+    assertThat(inMemoryProjectCache.get(Project.nameKey("foo"))).isEmpty();
+    assertThat(inMemoryProjectCache.stats().missCount()).isEqualTo(initialNumMisses + 1);
+    inMemoryProjectCache.get(Project.nameKey("foo")); // Another invocation
+    assertThat(inMemoryProjectCache.stats().missCount()).isEqualTo(initialNumMisses + 1);
+  }
+
+  @Test
+  public void invalidatesNegativeCachingAfterProjectCreation() throws Exception {
+    long initialNumMisses = inMemoryProjectCache.stats().missCount();
+    assertThat(inMemoryProjectCache.get(Project.nameKey(name("foo")))).isEmpty();
+    assertThat(inMemoryProjectCache.stats().missCount())
+        .isEqualTo(initialNumMisses + 1); // Negative voting cached
+    Project.NameKey newProjectName =
+        createProjectOverAPI("foo", allProjects, true, /* submitType= */ null);
+    assertThat(inMemoryProjectCache.get(newProjectName)).isPresent(); // Another invocation
+    assertThat(inMemoryProjectCache.stats().missCount())
+        .isEqualTo(initialNumMisses + 3); // Two eviction happened during the project creation
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index 055da007..37e323a 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -45,7 +45,7 @@
 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.project.SubmitRequirementsEvaluatorImpl;
 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;
@@ -60,7 +60,7 @@
 
 @NoHttpd
 public class SubmitRequirementsEvaluatorIT extends AbstractDaemonTest {
-  @Inject SubmitRequirementsEvaluator evaluator;
+  @Inject SubmitRequirementsEvaluatorImpl evaluator;
   @Inject private ProjectOperations projectOperations;
   @Inject private Provider<InternalChangeQuery> changeQueryProvider;
   @Inject private ExtensionRegistry extensionRegistry;
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
index a643d56..9170214 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
@@ -18,14 +18,36 @@
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.GitUtil.fetch;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+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.RefNames;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
+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.server.CurrentUser;
 import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.List;
 import java.util.Locale;
 import java.util.function.Consumer;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.transport.PushResult;
@@ -447,6 +469,137 @@
     r.assertOkStatus();
   }
 
+  protected static class IsOperatorModule extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(ChangeQueryBuilder.ChangeIsOperandFactory.class)
+          .annotatedWith(Exports.named("changeNumberEven"))
+          .to(SampleIsOperand.class);
+    }
+  }
+
+  private static class SampleIsOperand implements ChangeQueryBuilder.ChangeIsOperandFactory {
+    final Provider<CurrentUser> currentUserProvider;
+
+    @Inject
+    SampleIsOperand(Provider<CurrentUser> currentUserProvider) {
+      this.currentUserProvider = currentUserProvider;
+    }
+
+    @Override
+    public Predicate<ChangeData> create(ChangeQueryBuilder builder) throws QueryParseException {
+      return new IsSamplePredicate(currentUserProvider.get());
+    }
+  }
+
+  private static class IsSamplePredicate extends OperatorPredicate<ChangeData>
+      implements Matchable<ChangeData> {
+
+    CurrentUser currentUser;
+
+    public IsSamplePredicate(CurrentUser currentUser) {
+      super("is", "changeNumberEven");
+      this.currentUser = currentUser;
+      assertServerUser();
+    }
+
+    private void assertServerUser() {
+      try {
+        currentUser.asIdentifiedUser();
+        throw new IllegalStateException("is an identified user");
+      } catch (UnsupportedOperationException e) {
+        // as expected.
+      }
+    }
+
+    @Override
+    public boolean match(ChangeData changeData) {
+      assertServerUser();
+      return true;
+    }
+
+    @Override
+    public int getCost() {
+      return 0;
+    }
+  }
+
+  @Test
+  public void submitRequirementValidationRunsAsServer() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo.delete(RefNames.REFS_CONFIG);
+    }
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    try (AutoCloseable ignored = installPlugin("myplugin", IsOperatorModule.class)) {
+      PushOneCommit push =
+          pushFactory
+              .create(
+                  admin.newIdent(),
+                  testRepo,
+                  "Test Change",
+                  ProjectConfig.PROJECT_CONFIG,
+                  "[submit-requirement \"SAMPLE\"]\n"
+                      + "  submittableIf = is:changeNumberEven_myplugin\n")
+              .setParents(ImmutableList.of());
+      PushOneCommit.Result cfgPush = push.to(RefNames.REFS_CONFIG);
+      cfgPush.assertOkStatus();
+
+      ChangeInfo info = gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+      List<SubmitRequirementResultInfo> results =
+          info.submitRequirements.stream()
+              .filter(x -> x.name.equals("SAMPLE"))
+              .collect(Collectors.toList());
+      assertThat(results).hasSize(1);
+      assertThat(results.get(0).status).isNotEqualTo(SubmitRequirementResultInfo.Status.ERROR);
+    }
+
+    // Unloaded the plugin, the SR will fail now.
+
+    ChangeInfo info = gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS);
+    List<SubmitRequirementResultInfo> results =
+        info.submitRequirements.stream()
+            .filter(x -> x.name.equals("SAMPLE"))
+            .collect(Collectors.toList());
+    assertThat(results).hasSize(1);
+    assertThat(results.get(0).status).isEqualTo(SubmitRequirementResultInfo.Status.ERROR);
+  }
+
+  @GerritConfig(name = "change.propagateSubmitRequirementErrors", value = "true")
+  @Test
+  public void submitRequirementPropagateErrorFlag() throws Exception {
+    try (TestRepository<Repository> testRepo =
+        new TestRepository<>(repoManager.openRepository(project))) {
+      testRepo.delete(RefNames.REFS_CONFIG);
+    }
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    try (AutoCloseable ignored = installPlugin("myplugin", IsOperatorModule.class)) {
+      PushOneCommit push =
+          pushFactory
+              .create(
+                  admin.newIdent(),
+                  testRepo,
+                  "Test Change",
+                  ProjectConfig.PROJECT_CONFIG,
+                  "[submit-requirement \"SAMPLE\"]\n"
+                      + "  submittableIf = is:changeNumberEven_myplugin\n")
+              .setParents(ImmutableList.of());
+      PushOneCommit.Result cfgPush = push.to(RefNames.REFS_CONFIG);
+      cfgPush.assertOkStatus();
+    }
+    StorageException thrown =
+        assertThrows(
+            StorageException.class,
+            () -> gApi.changes().id(changeId).get(ListChangesOption.SUBMIT_REQUIREMENTS));
+    assertThat(thrown).hasMessageThat().contains("changeNumberEven_myplugin");
+  }
+
   @Test
   public void invalidSubmitRequirementIsRejectedWhenPushingForReview() throws Exception {
     fetchRefsMetaConfig();
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/BUILD b/javatests/com/google/gerrit/acceptance/server/rules/BUILD
index 1f547f7..911c85c 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/rules/BUILD
@@ -4,7 +4,4 @@
     srcs = glob(["*IT.java"]),
     group = "server_rules",
     labels = ["server"],
-    deps = [
-        "@prolog-runtime//jar",
-    ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/prolog/BUILD b/javatests/com/google/gerrit/acceptance/server/rules/prolog/BUILD
new file mode 100644
index 0000000..03c24a2
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/rules/prolog/BUILD
@@ -0,0 +1,11 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "prolog_rules",
+    labels = ["server"],
+    deps = [
+        "//java/com/google/gerrit/server/rules/prolog",
+        "//lib/prolog:runtime",
+    ],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/rules/prolog/PrologRuleEvaluatorIT.java
similarity index 96%
rename from javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
rename to javatests/com/google/gerrit/acceptance/server/rules/prolog/PrologRuleEvaluatorIT.java
index bf8b1f8..850fe8e 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/prolog/PrologRuleEvaluatorIT.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.acceptance.server.rules;
+package com.google.gerrit.acceptance.server.rules.prolog;
 
 import static com.google.common.truth.Truth.assertThat;
 
@@ -23,8 +23,8 @@
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.rules.PrologOptions;
-import com.google.gerrit.server.rules.PrologRuleEvaluator;
+import com.google.gerrit.server.rules.prolog.PrologOptions;
+import com.google.gerrit.server.rules.prolog.PrologRuleEvaluator;
 import com.google.gerrit.testing.TestChanges;
 import com.google.inject.Inject;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java b/javatests/com/google/gerrit/acceptance/server/rules/prolog/RulesIT.java
similarity index 99%
rename from javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
rename to javatests/com/google/gerrit/acceptance/server/rules/prolog/RulesIT.java
index 2938065..74bdb56 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/prolog/RulesIT.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.acceptance.server.rules;
+package com.google.gerrit.acceptance.server.rules.prolog;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
diff --git a/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java b/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
index 1b04e80..2bfc072 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
@@ -22,6 +22,8 @@
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -37,6 +39,7 @@
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import java.util.stream.StreamSupport;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -97,6 +100,86 @@
                 == 2);
   }
 
+  @Test
+  @GerritConfig(name = "event.stream-events.enableBatchRefUpdatedEvents", value = "true")
+  public void batchRefsUpdatedShowsInStreamEvents() throws Exception {
+    ChangeData change = createChange().getChange();
+    String patchsetRefName = change.currentPatchSet().refName();
+    String metaRefName = RefNames.changeMetaRef(change.getId());
+    waitForEvent(
+        () -> pollEventsContaining("batch-ref-updated", patchsetRefName, metaRefName).size() == 1);
+  }
+
+  @Test
+  @GerritConfig(name = "event.stream-events.enableRefUpdatedEvents", value = "true")
+  @GerritConfig(name = "event.stream-events.enableBatchRefUpdatedEvents", value = "false")
+  @GerritConfig(name = "event.stream-events.enableDraftCommentEvents", value = "true")
+  public void draftCommentRefsShowInStreamEventsWithRefUpdated() throws Exception {
+    change = createChange().getChange();
+
+    draftReviewChange(PATCHSET_LEVEL, String.format("%s 1", TEST_REVIEW_DRAFT_COMMENT));
+
+    waitForEvent(() -> pollEventsContaining("ref-updated", "refs/draft-comments/").size() == 1);
+  }
+
+  @Test(expected = InterruptedException.class)
+  @GerritConfig(name = "event.stream-events.enableRefUpdatedEvents", value = "true")
+  @GerritConfig(name = "event.stream-events.enableBatchRefUpdatedEvents", value = "false")
+  @GerritConfig(name = "event.stream-events.enableDraftCommentEvents", value = "false")
+  public void draftCommentRefsDontShowInStreamEventsWithRefUpdated() throws Exception {
+    change = createChange().getChange();
+
+    draftReviewChange(PATCHSET_LEVEL, String.format("%s 1", TEST_REVIEW_DRAFT_COMMENT));
+
+    waitForEvent(() -> pollEventsContaining("ref-updated", "refs/draft-comments/").size() == 1);
+  }
+
+  @Test
+  @GerritConfig(name = "event.stream-events.enableBatchRefUpdatedEvents", value = "true")
+  @GerritConfig(name = "event.stream-events.enableRefUpdatedEvents", value = "false")
+  @GerritConfig(name = "event.stream-events.enableDraftCommentEvents", value = "true")
+  public void draftCommentRefsShowInStreamEventsWithBatchRefUpdated() throws Exception {
+    change = createChange().getChange();
+
+    draftReviewChange(PATCHSET_LEVEL, String.format("%s 1", TEST_REVIEW_DRAFT_COMMENT));
+
+    waitForEvent(
+        () -> pollEventsContaining("batch-ref-updated", "refs/draft-comments/").size() == 1);
+  }
+
+  @Test(expected = InterruptedException.class)
+  @GerritConfig(name = "event.stream-events.enableBatchRefUpdatedEvents", value = "true")
+  @GerritConfig(name = "event.stream-events.enableRefUpdatedEvents", value = "false")
+  @GerritConfig(name = "event.stream-events.enableDraftCommentEvents", value = "false")
+  public void draftCommentRefsDontShowInStreamEventsWithBatchRefUpdated() throws Exception {
+    change = createChange().getChange();
+
+    draftReviewChange(PATCHSET_LEVEL, String.format("%s 1", TEST_REVIEW_DRAFT_COMMENT));
+
+    waitForEvent(
+        () -> pollEventsContaining("batch-ref-updated", "refs/draft-comments/").size() == 1);
+  }
+
+  @Test
+  @GerritConfig(name = "event.stream-events.enableRefUpdatedEvents", value = "true")
+  @GerritConfig(name = "event.stream-events.enableBatchRefUpdatedEvents", value = "false")
+  @GerritConfig(name = "event.stream-events.enableDraftCommentEvents", value = "true")
+  public void draftCommentRefsDeletionShowInStreamEventsUponPublishing() throws Exception {
+    change = createChange().getChange();
+
+    draftReviewChange(PATCHSET_LEVEL, String.format("%s 1", TEST_REVIEW_DRAFT_COMMENT));
+    publishDraftReviews();
+
+    waitForEvent(
+        () ->
+            pollEventsContaining(
+                        "ref-updated",
+                        "refs/draft-comments/",
+                        "\"newRev\":\"" + ObjectId.zeroId().name() + "\"")
+                    .size()
+                == 1);
+  }
+
   private void waitForEvent(Supplier<Boolean> waitCondition) throws InterruptedException {
     waitUntil(() -> waitCondition.get(), MAX_DURATION_FOR_RECEIVING_EVENTS);
   }
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
index 48fd38c..4241511 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.Sequences;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.util.concurrent.atomic.AtomicInteger;
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
index 447b625..8b06c7f 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
@@ -47,6 +47,7 @@
             .groups(ImmutableList.of("group1", " group2"))
             .pushCertificate("my push certificate")
             .description("This is a patch set description.")
+            .branch(Optional.of("refs/heads/master"))
             .build();
 
     Entities.PatchSet proto = patchSetProtoConverter.toProto(patchSet);
@@ -65,6 +66,7 @@
             .setGroups("group1, group2")
             .setPushCertificate("my push certificate")
             .setDescription("This is a patch set description.")
+            .setBranch("refs/heads/master")
             .build();
     assertThat(proto).isEqualTo(expectedProto);
   }
@@ -109,6 +111,7 @@
             .groups(ImmutableList.of("group1", " group2"))
             .pushCertificate("my push certificate")
             .description("This is a patch set description.")
+            .branch(Optional.of("refs/heads/master"))
             .build();
 
     PatchSet convertedPatchSet =
@@ -191,6 +194,7 @@
                 .put("groups", new TypeLiteral<ImmutableList<String>>() {}.getType())
                 .put("pushCertificate", new TypeLiteral<Optional<String>>() {}.getType())
                 .put("description", new TypeLiteral<Optional<String>>() {}.getType())
+                .put("branch", new TypeLiteral<Optional<String>>() {}.getType())
                 .build());
   }
 }
diff --git a/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java b/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
index 8bafafe..c360b2f 100644
--- a/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
+++ b/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
@@ -279,7 +279,6 @@
 
   private PGPPublicKeyRing removeRevokers(PGPPublicKeyRing kr) {
     PGPPublicKey k = kr.getPublicKey();
-    @SuppressWarnings("unchecked")
     Iterator<PGPSignature> sigs = k.getSignaturesOfType(DIRECT_KEY);
     while (sigs.hasNext()) {
       PGPSignature sig = sigs.next();
diff --git a/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java b/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
index 04f9827..0389c4f 100644
--- a/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
+++ b/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
@@ -38,6 +38,7 @@
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.PasswordVerifier;
+import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdFactoryNoteDbImpl;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
 import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
@@ -100,7 +101,7 @@
     res = new FakeHttpServletResponse();
 
     extIdKeyFactory = new ExternalIdKeyFactory(new ExternalIdKeyFactory.ConfigImpl(authConfig));
-    extIdFactory = new ExternalIdFactory(extIdKeyFactory, authConfig);
+    extIdFactory = new ExternalIdFactoryNoteDbImpl(extIdKeyFactory, authConfig);
     authRequestFactory = new AuthRequest.Factory(extIdKeyFactory);
     pwdVerifier = new PasswordVerifier(extIdKeyFactory, authConfig);
 
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
index 6ff426a..03b14e6 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", "1916314",
+            "defaultChangeDetailHex", "9916394",
             "changeRequestsPath", "changes/project~123");
   }
 
diff --git a/javatests/com/google/gerrit/httpd/raw/StaticModuleTest.java b/javatests/com/google/gerrit/httpd/raw/StaticModuleTest.java
new file mode 100644
index 0000000..e973a26
--- /dev/null
+++ b/javatests/com/google/gerrit/httpd/raw/StaticModuleTest.java
@@ -0,0 +1,47 @@
+// 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.httpd.raw;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.httpd.raw.StaticModule.PolyGerritFilter.isPolyGerritIndex;
+
+import org.junit.Test;
+
+public class StaticModuleTest {
+
+  @Test
+  public void doNotMatchPolyGerritIndex() {
+    assertThat(isPolyGerritIndex("/123456")).isFalse();
+    assertThat(isPolyGerritIndex("/123456/")).isFalse();
+    assertThat(isPolyGerritIndex("/123456/1")).isFalse();
+    assertThat(isPolyGerritIndex("/123456/1/")).isFalse();
+    assertThat(isPolyGerritIndex("/c/123456/comment/9ab75172_67d798e1")).isFalse();
+    assertThat(isPolyGerritIndex("/123456/comment/9ab75172_67d798e1")).isFalse();
+    assertThat(isPolyGerritIndex("/123456/comment/9ab75172_67d798e1/")).isFalse();
+    assertThat(isPolyGerritIndex("/123456/1..2")).isFalse();
+    assertThat(isPolyGerritIndex("/c/123456/1..2")).isFalse();
+    assertThat(isPolyGerritIndex("/c/2/1/COMMIT_MSG")).isFalse();
+    assertThat(isPolyGerritIndex("/c/2/1/path/to/source/file/MyClass.java")).isFalse();
+  }
+
+  @Test
+  public void matchPolyGerritIndex() {
+    assertThat(isPolyGerritIndex("/c/test/+/123456/anyString")).isTrue();
+    assertThat(isPolyGerritIndex("/c/test/+/123456/comment/9ab75172_67d798e1")).isTrue();
+    assertThat(isPolyGerritIndex("/c/321/+/123456/anyString")).isTrue();
+    assertThat(isPolyGerritIndex("/c/321/+/123456/comment/9ab75172_67d798e1")).isTrue();
+    assertThat(isPolyGerritIndex("/c/321/anyString")).isTrue();
+  }
+}
diff --git a/javatests/com/google/gerrit/index/query/AndSourceTest.java b/javatests/com/google/gerrit/index/query/AndSourceTest.java
index 068ae8c..6f20bd7 100644
--- a/javatests/com/google/gerrit/index/query/AndSourceTest.java
+++ b/javatests/com/google/gerrit/index/query/AndSourceTest.java
@@ -20,15 +20,24 @@
 import static org.junit.Assert.assertTrue;
 
 import com.google.common.collect.Lists;
+import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.testing.ConfigSuite;
+import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
+import org.junit.runner.RunWith;
 
+@RunWith(ConfigSuite.class)
 public class AndSourceTest extends PredicateTest {
+
+  @ConfigSuite.Parameter public Config config;
+
   @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);
+    AndSource<String> andSource =
+        new AndSource<>(Lists.newArrayList(p1, p2), IndexConfig.fromConfig(config).build());
     assertFalse(andSource.match("bar"));
     assertFalse(p1.ranMatch);
     assertTrue(p2.ranMatch);
diff --git a/javatests/com/google/gerrit/index/query/PredicateTest.java b/javatests/com/google/gerrit/index/query/PredicateTest.java
index d789201..1853d98 100644
--- a/javatests/com/google/gerrit/index/query/PredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/PredicateTest.java
@@ -14,10 +14,32 @@
 
 package com.google.gerrit.index.query;
 
+import com.google.gerrit.testing.ConfigSuite;
+import org.eclipse.jgit.lib.Config;
 import org.junit.Ignore;
 
 @Ignore
 public abstract class PredicateTest {
+
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return com.google.gerrit.testing.IndexConfig.create();
+  }
+
+  @ConfigSuite.Config
+  public static Config searchAfterPaginationType() {
+    Config config = defaultConfig();
+    config.setString("index", null, "paginationType", "SEARCH_AFTER");
+    return config;
+  }
+
+  @ConfigSuite.Config
+  public static Config nonePaginationType() {
+    Config config = defaultConfig();
+    config.setString("index", null, "paginationType", "NONE");
+    return config;
+  }
+
   @SuppressWarnings("ProtectedMembersInFinalClass")
   protected static class TestDataSourcePredicate extends TestMatchablePredicate<String>
       implements DataSource<String> {
diff --git a/javatests/com/google/gerrit/metrics/dropwizard/BucketedCallbackTest.java b/javatests/com/google/gerrit/metrics/dropwizard/BucketedCallbackTest.java
index e87a208..9c66dc6 100644
--- a/javatests/com/google/gerrit/metrics/dropwizard/BucketedCallbackTest.java
+++ b/javatests/com/google/gerrit/metrics/dropwizard/BucketedCallbackTest.java
@@ -93,9 +93,8 @@
     String name(Object key) {
       if (key.equals(COLLIDING_KEY_NAME1) || key.equals(COLLIDING_KEY_NAME2)) {
         return COLLIDING_SUBMETRIC_NAME;
-      } else {
-        return key.toString();
       }
+      return key.toString();
     }
   }
 }
diff --git a/javatests/com/google/gerrit/proto/BUILD b/javatests/com/google/gerrit/proto/BUILD
index 6b98b72..10cb0b4 100644
--- a/javatests/com/google/gerrit/proto/BUILD
+++ b/javatests/com/google/gerrit/proto/BUILD
@@ -5,7 +5,8 @@
     srcs = glob(["*.java"]),
     deps = [
         "//java/com/google/gerrit/proto",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//java/com/google/gerrit/testing:gerrit-junit",
+        "//lib:guava",
         "//lib:protobuf",
         "//lib/truth",
         "//lib/truth:truth-proto-extension",
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 4821f20..0c84093 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -34,6 +34,7 @@
     ],
     deps = [
         ":custom-truth-subjects",
+        "//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",
@@ -77,6 +78,7 @@
         "//lib:jgit",
         "//lib:jgit-junit",
         "//lib:protobuf",
+        "//lib:roaringbitmap",
         "//lib:soy",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
diff --git a/javatests/com/google/gerrit/server/account/AccountCacheTest.java b/javatests/com/google/gerrit/server/account/AccountCacheTest.java
index c1eff15..6628362 100644
--- a/javatests/com/google/gerrit/server/account/AccountCacheTest.java
+++ b/javatests/com/google/gerrit/server/account/AccountCacheTest.java
@@ -21,9 +21,11 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.NotifyConfig;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.proto.Entities;
 import com.google.gerrit.server.cache.proto.Cache;
 import com.google.gerrit.server.config.CachedPreferences;
 import java.time.Instant;
+import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
 /**
@@ -49,7 +51,7 @@
             .setPreferredEmail("foo@bar.tld")
             .build();
     CachedAccountDetails original =
-        CachedAccountDetails.create(account, ImmutableMap.of(), CachedPreferences.fromString(""));
+        CachedAccountDetails.create(account, ImmutableMap.of(), CachedPreferences.EMPTY);
     byte[] serialized = SERIALIZER.serialize(original);
     Cache.AccountDetailsProto expected =
         Cache.AccountDetailsProto.newBuilder()
@@ -71,7 +73,7 @@
   @Test
   public void account_roundTripNullFields() throws Exception {
     CachedAccountDetails original =
-        CachedAccountDetails.create(ACCOUNT, ImmutableMap.of(), CachedPreferences.fromString(""));
+        CachedAccountDetails.create(ACCOUNT, ImmutableMap.of(), CachedPreferences.EMPTY);
     byte[] serialized = SERIALIZER.serialize(original);
     Cache.AccountDetailsProto expected =
         Cache.AccountDetailsProto.newBuilder().setAccount(ACCOUNT_PROTO).build();
@@ -80,16 +82,40 @@
   }
 
   @Test
-  public void config_roundTrip() throws Exception {
+  public void config_gitConfig_roundTrip() throws Exception {
+    Config cfg = new Config();
+    cfg.fromText("[general]\n\tfoo = bar");
     CachedAccountDetails original =
         CachedAccountDetails.create(
-            ACCOUNT, ImmutableMap.of(), CachedPreferences.fromString("[general]\n\tfoo = bar"));
+            ACCOUNT, ImmutableMap.of(), CachedPreferences.fromLegacyConfig(cfg));
 
     byte[] serialized = SERIALIZER.serialize(original);
     Cache.AccountDetailsProto expected =
         Cache.AccountDetailsProto.newBuilder()
             .setAccount(ACCOUNT_PROTO)
-            .setUserPreferences("[general]\n\tfoo = bar")
+            .setUserPreferences(
+                Cache.CachedPreferencesProto.newBuilder().setLegacyGitConfig(cfg.toText()))
+            .build();
+    ProtoTruth.assertThat(Cache.AccountDetailsProto.parseFrom(serialized)).isEqualTo(expected);
+    Truth.assertThat(SERIALIZER.deserialize(serialized)).isEqualTo(original);
+  }
+
+  @Test
+  public void config_protoConfig_roundTrip() throws Exception {
+    Entities.UserPreferences proto =
+        Entities.UserPreferences.newBuilder()
+            .setGeneralPreferencesInfo(
+                Entities.UserPreferences.GeneralPreferencesInfo.newBuilder().setChangesPerPage(17))
+            .build();
+    CachedAccountDetails original =
+        CachedAccountDetails.create(
+            ACCOUNT, ImmutableMap.of(), CachedPreferences.fromUserPreferencesProto(proto));
+
+    byte[] serialized = SERIALIZER.serialize(original);
+    Cache.AccountDetailsProto expected =
+        Cache.AccountDetailsProto.newBuilder()
+            .setAccount(ACCOUNT_PROTO)
+            .setUserPreferences(Cache.CachedPreferencesProto.newBuilder().setUserPreferences(proto))
             .build();
     ProtoTruth.assertThat(Cache.AccountDetailsProto.parseFrom(serialized)).isEqualTo(expected);
     Truth.assertThat(SERIALIZER.deserialize(serialized)).isEqualTo(original);
@@ -103,7 +129,7 @@
         CachedAccountDetails.create(
             ACCOUNT,
             ImmutableMap.of(key, ImmutableSet.of(NotifyConfig.NotifyType.ALL_COMMENTS)),
-            CachedPreferences.fromString(""));
+            CachedPreferences.EMPTY);
 
     byte[] serialized = SERIALIZER.serialize(original);
     Cache.AccountDetailsProto expected =
@@ -127,7 +153,7 @@
         CachedAccountDetails.create(
             ACCOUNT,
             ImmutableMap.of(key, ImmutableSet.of(NotifyConfig.NotifyType.ALL_COMMENTS)),
-            CachedPreferences.fromString(""));
+            CachedPreferences.EMPTY);
 
     byte[] serialized = SERIALIZER.serialize(original);
     Cache.AccountDetailsProto expected =
diff --git a/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java b/javatests/com/google/gerrit/server/account/externalids/storage/notedb/AllExternalIdsTest.java
similarity index 93%
rename from javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java
rename to javatests/com/google/gerrit/server/account/externalids/storage/notedb/AllExternalIdsTest.java
index eb2133e..d36f62b 100644
--- a/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java
+++ b/javatests/com/google/gerrit/server/account/externalids/storage/notedb/AllExternalIdsTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.account.externalids;
+package com.google.gerrit.server.account.externalids.storage.notedb;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
@@ -22,7 +22,9 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.server.account.externalids.AllExternalIds.Serializer;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
+import com.google.gerrit.server.account.externalids.storage.notedb.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;
@@ -35,14 +37,14 @@
 import org.mockito.Mock;
 
 public class AllExternalIdsTest {
-  private ExternalIdFactory externalIdFactory;
+  private ExternalIdFactoryNoteDbImpl externalIdFactory;
 
   @Mock AuthConfig authConfig;
 
   @Before
   public void setUp() throws Exception {
     externalIdFactory =
-        new ExternalIdFactory(
+        new ExternalIdFactoryNoteDbImpl(
             new ExternalIdKeyFactory(
                 new ExternalIdKeyFactory.Config() {
                   @Override
diff --git a/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java b/javatests/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIDCacheLoaderTest.java
similarity index 90%
rename from javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
rename to javatests/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIDCacheLoaderTest.java
index d270138..03e4278 100644
--- a/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
+++ b/javatests/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIDCacheLoaderTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.account.externalids;
+package com.google.gerrit.server.account.externalids.storage.notedb;
 
 import static com.google.common.truth.Truth.assertThat;
 import static org.mockito.Mockito.times;
@@ -23,9 +23,12 @@
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
 import com.google.common.collect.ImmutableList;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.metrics.DisabledMetricMaker;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.testing.ExternalIdTestUtil;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
@@ -62,12 +65,13 @@
   private ExternalIdReader externalIdReader;
   private ExternalIdReader externalIdReaderSpy;
 
-  private ExternalIdFactory externalIdFactory;
+  private ExternalIdFactoryNoteDbImpl externalIdFactory;
   @Mock private AuthConfig authConfig;
 
   @Before
   public void setUp() throws Exception {
-    externalIdFactory = new ExternalIdFactory(new ExternalIdKeyFactory(() -> false), authConfig);
+    externalIdFactory =
+        new ExternalIdFactoryNoteDbImpl(new ExternalIdKeyFactory(() -> false), authConfig);
     externalIdCache = CacheBuilder.newBuilder().build();
     repoManager.createRepository(ALL_USERS).close();
     externalIdReader =
@@ -104,21 +108,23 @@
 
     Repository repo = repoManager.openRepository(ALL_USERS);
     ObjectId newState = insertExternalId(key, account);
-    TreeWalk tw = new TreeWalk(repo);
-    tw.reset(new RevWalk(repo).parseCommit(newState).getTree());
-    tw.next();
+    try (TreeWalk tw = new TreeWalk(repo);
+        RevWalk rw = new RevWalk(repo)) {
+      tw.reset(rw.parseCommit(newState).getTree());
+      tw.next();
 
-    HashMap<ObjectId, ObjectId> additions = new HashMap<>();
-    additions.put(fileNameToObjectId(tw.getPathString()), tw.getObjectId(0));
-    AllExternalIds oldExternalIds =
-        AllExternalIds.create(Stream.<ExternalId>builder().add(externalId).build());
+      HashMap<ObjectId, ObjectId> additions = new HashMap<>();
+      additions.put(fileNameToObjectId(tw.getPathString()), tw.getObjectId(0));
+      AllExternalIds oldExternalIds =
+          AllExternalIds.create(Stream.<ExternalId>builder().add(externalId).build());
 
-    AllExternalIds allExternalIds =
-        loader.buildAllExternalIds(repo, oldExternalIds, additions, new HashSet<>());
+      AllExternalIds allExternalIds =
+          loader.buildAllExternalIds(repo, oldExternalIds, additions, new HashSet<>());
 
-    assertThat(allExternalIds).isNotNull();
-    assertThat(allExternalIds.byKey().containsKey(externalIdKey)).isTrue();
-    assertThat(allExternalIds.byKey().get(externalIdKey)).isEqualTo(externalId);
+      assertThat(allExternalIds).isNotNull();
+      assertThat(allExternalIds.byKey().containsKey(externalIdKey)).isTrue();
+      assertThat(allExternalIds.byKey().get(externalIdKey)).isEqualTo(externalId);
+    }
   }
 
   private static ObjectId fileNameToObjectId(String path) {
@@ -267,6 +273,7 @@
     return oldState;
   }
 
+  @CanIgnoreReturnValue
   private ObjectId insertExternalId(int key, int accountId) throws Exception {
     return performExternalIdUpdate(
         u -> {
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
index 8c4eb08..b7f566db 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -72,7 +72,8 @@
         1 << 20,
         expireAfterWrite,
         refreshAfterWrite,
-        true);
+        true,
+        false);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/cache/mem/BUILD b/javatests/com/google/gerrit/server/cache/mem/BUILD
index baa6ff8..81b0b2e 100644
--- a/javatests/com/google/gerrit/server/cache/mem/BUILD
+++ b/javatests/com/google/gerrit/server/cache/mem/BUILD
@@ -9,6 +9,7 @@
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/cache/mem",
+        "//lib:guava",
         "//lib:jgit",
         "//lib:junit",
         "//lib/guice",
diff --git a/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java b/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java
new file mode 100644
index 0000000..772f4b8
--- /dev/null
+++ b/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java
@@ -0,0 +1,161 @@
+// 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.config;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.EditPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.proto.Entities.UserPreferences;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class CachedPreferencesTest {
+  @Test
+  public void gitConfig_roundTrip() throws Exception {
+    Config originalCfg = new Config();
+    originalCfg.fromText("[general]\n\tfoo = bar");
+
+    CachedPreferences pref = CachedPreferences.fromLegacyConfig(originalCfg);
+    Config res = pref.asConfig();
+
+    assertThat(res.toText()).isEqualTo(originalCfg.toText());
+  }
+
+  @Test
+  public void gitConfig_getGeneralPreferences() throws Exception {
+    Config originalCfg = new Config();
+    originalCfg.fromText("[general]\n\tchangesPerPage = 2");
+
+    CachedPreferences pref = CachedPreferences.fromLegacyConfig(originalCfg);
+    GeneralPreferencesInfo general = CachedPreferences.general(Optional.empty(), pref);
+
+    assertThat(general.changesPerPage).isEqualTo(2);
+  }
+
+  @Test
+  public void gitConfig_getDiffPreferences() throws Exception {
+    Config originalCfg = new Config();
+    originalCfg.fromText("[diff]\n\tcontext = 3");
+
+    CachedPreferences pref = CachedPreferences.fromLegacyConfig(originalCfg);
+    DiffPreferencesInfo diff = CachedPreferences.diff(Optional.empty(), pref);
+
+    assertThat(diff.context).isEqualTo(3);
+  }
+
+  @Test
+  public void gitConfig_getEditPreferences() throws Exception {
+    Config originalCfg = new Config();
+    originalCfg.fromText("[edit]\n\ttabSize = 5");
+
+    CachedPreferences pref = CachedPreferences.fromLegacyConfig(originalCfg);
+    EditPreferencesInfo edit = CachedPreferences.edit(Optional.empty(), pref);
+
+    assertThat(edit.tabSize).isEqualTo(5);
+  }
+
+  @Test
+  public void userPreferencesProto_roundTrip() throws Exception {
+    UserPreferences originalProto =
+        UserPreferences.newBuilder()
+            .setGeneralPreferencesInfo(
+                UserPreferences.GeneralPreferencesInfo.newBuilder().setChangesPerPage(7))
+            .build();
+
+    CachedPreferences pref = CachedPreferences.fromUserPreferencesProto(originalProto);
+    UserPreferences res = pref.asUserPreferencesProto();
+
+    assertThat(res).isEqualTo(originalProto);
+  }
+
+  @Test
+  public void userPreferencesProto_getGeneralPreferences() throws Exception {
+    UserPreferences originalProto =
+        UserPreferences.newBuilder()
+            .setGeneralPreferencesInfo(
+                UserPreferences.GeneralPreferencesInfo.newBuilder().setChangesPerPage(11))
+            .build();
+
+    CachedPreferences pref = CachedPreferences.fromUserPreferencesProto(originalProto);
+    GeneralPreferencesInfo general = CachedPreferences.general(Optional.empty(), pref);
+
+    assertThat(general.changesPerPage).isEqualTo(11);
+  }
+
+  @Test
+  public void userPreferencesProto_getDiffPreferences() throws Exception {
+    UserPreferences originalProto =
+        UserPreferences.newBuilder()
+            .setDiffPreferencesInfo(UserPreferences.DiffPreferencesInfo.newBuilder().setContext(13))
+            .build();
+
+    CachedPreferences pref = CachedPreferences.fromUserPreferencesProto(originalProto);
+    DiffPreferencesInfo diff = CachedPreferences.diff(Optional.empty(), pref);
+
+    assertThat(diff.context).isEqualTo(13);
+  }
+
+  @Test
+  public void userPreferencesProto_getEditPreferences() throws Exception {
+    UserPreferences originalProto =
+        UserPreferences.newBuilder()
+            .setEditPreferencesInfo(UserPreferences.EditPreferencesInfo.newBuilder().setTabSize(17))
+            .build();
+
+    CachedPreferences pref = CachedPreferences.fromUserPreferencesProto(originalProto);
+    EditPreferencesInfo edit = CachedPreferences.edit(Optional.empty(), pref);
+
+    assertThat(edit.tabSize).isEqualTo(17);
+  }
+
+  @Test
+  public void defaultPreferences_acceptingGitConfig() throws Exception {
+    Config cfg = new Config();
+    cfg.fromText("[general]\n\tchangesPerPage = 19");
+    CachedPreferences defaults = CachedPreferences.fromLegacyConfig(cfg);
+    CachedPreferences userPreferences =
+        CachedPreferences.fromUserPreferencesProto(UserPreferences.getDefaultInstance());
+
+    assertThat(CachedPreferences.general(Optional.of(defaults), userPreferences)).isNotNull();
+    assertThat(CachedPreferences.diff(Optional.of(defaults), userPreferences)).isNotNull();
+    assertThat(CachedPreferences.edit(Optional.of(defaults), userPreferences)).isNotNull();
+  }
+
+  @Test
+  public void defaultPreferences_throwingForProto() throws Exception {
+    CachedPreferences defaults =
+        CachedPreferences.fromUserPreferencesProto(UserPreferences.getDefaultInstance());
+    CachedPreferences userPreferences =
+        CachedPreferences.fromUserPreferencesProto(UserPreferences.getDefaultInstance());
+    assertThrows(
+        StorageException.class,
+        () -> CachedPreferences.general(Optional.of(defaults), userPreferences));
+    assertThrows(
+        StorageException.class,
+        () -> CachedPreferences.diff(Optional.of(defaults), userPreferences));
+    assertThrows(
+        StorageException.class,
+        () -> CachedPreferences.edit(Optional.of(defaults), userPreferences));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java b/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java
new file mode 100644
index 0000000..c6ca3e4
--- /dev/null
+++ b/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java
@@ -0,0 +1,305 @@
+// 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.config;
+
+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.extensions.proto.ProtoTruth.assertThat;
+import static java.util.Arrays.stream;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo;
+import com.google.gerrit.extensions.client.EditPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.proto.Entities.UserPreferences;
+import com.google.gerrit.proto.Entities.UserPreferences.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.DateFormat;
+import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.DefaultBase;
+import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.DiffView;
+import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.EmailFormat;
+import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.EmailStrategy;
+import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.MenuItem;
+import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.Theme;
+import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.TimeFormat;
+import com.google.gerrit.server.config.UserPreferencesConverter.DiffPreferencesInfoConverter;
+import com.google.gerrit.server.config.UserPreferencesConverter.EditPreferencesInfoConverter;
+import com.google.gerrit.server.config.UserPreferencesConverter.GeneralPreferencesInfoConverter;
+import com.google.protobuf.Descriptors.Descriptor;
+import com.google.protobuf.Descriptors.EnumDescriptor;
+import java.util.EnumSet;
+import java.util.function.Function;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class UserPreferencesConverterTest {
+  @Test
+  public void generalPreferencesInfo_compareEnumNames() {
+    // The converter assumes that the enum type equivalents have exactly the same values in both
+    // classes. This test goes over all the enums to verify this assumption.
+    //
+    // If this test breaks, you are likely changing an enum. Please add it to the upstream Java
+    // class first, and on import - also update the proto version and the converter.
+    ImmutableMap<String, EnumDescriptor> protoEnums =
+        getProtoEnum(UserPreferences.GeneralPreferencesInfo.getDescriptor());
+    ImmutableMap<String, EnumSet<?>> javaEnums = getJavaEnums(GeneralPreferencesInfo.class);
+    assertThat(protoEnums.keySet()).containsExactlyElementsIn(javaEnums.keySet());
+    for (String enumName : protoEnums.keySet()) {
+      ImmutableList<String> protoEnumValues =
+          protoEnums.get(enumName).getValues().stream()
+              .map(v -> v.getName())
+              .collect(toImmutableList());
+      ImmutableList<String> javaEnumValues =
+          javaEnums.get(enumName).stream().map(Enum::name).collect(toImmutableList());
+      assertThat(protoEnumValues).containsExactlyElementsIn(javaEnumValues);
+    }
+  }
+
+  @Test
+  public void generalPreferencesInfo_doubleConversionWithAllFieldsSet() {
+    UserPreferences.GeneralPreferencesInfo originalProto =
+        UserPreferences.GeneralPreferencesInfo.newBuilder()
+            .setChangesPerPage(42)
+            .setDownloadScheme("DownloadScheme")
+            .setTheme(Theme.DARK)
+            .setDateFormat(DateFormat.UK)
+            .setTimeFormat(TimeFormat.HHMM_24)
+            .setExpandInlineDiffs(true)
+            .setRelativeDateInChangeTable(true)
+            .setDiffView(DiffView.UNIFIED_DIFF)
+            .setSizeBarInChangeTable(true)
+            .setLegacycidInChangeTable(true)
+            .setMuteCommonPathPrefixes(true)
+            .setSignedOffBy(true)
+            .setEmailStrategy(EmailStrategy.CC_ON_OWN_COMMENTS)
+            .setEmailFormat(EmailFormat.HTML_PLAINTEXT)
+            .setDefaultBaseForMerges(DefaultBase.FIRST_PARENT)
+            .setPublishCommentsOnPush(true)
+            .setDisableKeyboardShortcuts(true)
+            .setDisableTokenHighlighting(true)
+            .setWorkInProgressByDefault(true)
+            .addAllMyMenuItems(
+                ImmutableList.of(
+                    MenuItem.newBuilder()
+                        .setUrl("url1")
+                        .setName("name1")
+                        .setTarget("target1")
+                        .setId("id1")
+                        .build(),
+                    MenuItem.newBuilder()
+                        .setUrl("url2")
+                        .setName("name2")
+                        .setTarget("target2")
+                        .setId("id2")
+                        .build()))
+            .addAllChangeTable(ImmutableList.of("table1", "table2"))
+            .setAllowBrowserNotifications(true)
+            .setDiffPageSidebar("plugin-insight")
+            .build();
+    UserPreferences.GeneralPreferencesInfo resProto =
+        GeneralPreferencesInfoConverter.toProto(
+            GeneralPreferencesInfoConverter.fromProto(originalProto));
+    assertThat(resProto).isEqualTo(originalProto);
+  }
+
+  @Test
+  public void generalPreferencesInfo_emptyJavaToProto() {
+    GeneralPreferencesInfo info = new GeneralPreferencesInfo();
+    UserPreferences.GeneralPreferencesInfo res = GeneralPreferencesInfoConverter.toProto(info);
+    assertThat(res).isEqualToDefaultInstance();
+  }
+
+  @Test
+  public void generalPreferencesInfo_defaultJavaToProto() {
+    GeneralPreferencesInfo info = GeneralPreferencesInfo.defaults();
+    UserPreferences.GeneralPreferencesInfo res = GeneralPreferencesInfoConverter.toProto(info);
+    assertThat(res)
+        .ignoringFieldAbsence()
+        .isEqualTo(UserPreferences.GeneralPreferencesInfo.getDefaultInstance());
+  }
+
+  @Test
+  public void generalPreferencesInfo_emptyProtoToJava() {
+    UserPreferences.GeneralPreferencesInfo proto =
+        UserPreferences.GeneralPreferencesInfo.getDefaultInstance();
+    GeneralPreferencesInfo res = GeneralPreferencesInfoConverter.fromProto(proto);
+    assertThat(res).isEqualTo(new GeneralPreferencesInfo());
+  }
+
+  @Test
+  public void diffPreferencesInfo_compareEnumNames() {
+    // The converter assumes that the enum type equivalents have exactly the same values in both
+    // classes. This test goes over all the enums to verify this assumption.
+    //
+    // If this test breaks, you are likely changing an enum. Please add it to the upstream Java
+    // class first, and on import - also update the proto version and the converter.
+    ImmutableMap<String, EnumDescriptor> protoEnums =
+        getProtoEnum(UserPreferences.DiffPreferencesInfo.getDescriptor());
+    ImmutableMap<String, EnumSet<?>> javaEnums = getJavaEnums(DiffPreferencesInfo.class);
+    assertThat(protoEnums.keySet()).containsExactlyElementsIn(javaEnums.keySet());
+    for (String enumName : protoEnums.keySet()) {
+      ImmutableList<String> protoEnumValues =
+          protoEnums.get(enumName).getValues().stream()
+              .map(v -> v.getName())
+              .collect(toImmutableList());
+      ImmutableList<String> javaEnumValues =
+          javaEnums.get(enumName).stream().map(Enum::name).collect(toImmutableList());
+      assertThat(protoEnumValues).containsExactlyElementsIn(javaEnumValues);
+    }
+  }
+
+  @Test
+  public void diffPreferencesInfo_doubleConversionWithAllFieldsSet() {
+    UserPreferences.DiffPreferencesInfo originalProto =
+        UserPreferences.DiffPreferencesInfo.newBuilder()
+            .setContext(1)
+            .setTabSize(2)
+            .setFontSize(3)
+            .setLineLength(4)
+            .setCursorBlinkRate(5)
+            .setExpandAllComments(false)
+            .setIntralineDifference(true)
+            .setManualReview(false)
+            .setShowLineEndings(true)
+            .setShowTabs(false)
+            .setShowWhitespaceErrors(true)
+            .setSyntaxHighlighting(false)
+            .setHideTopMenu(true)
+            .setAutoHideDiffTableHeader(false)
+            .setHideLineNumbers(true)
+            .setRenderEntireFile(false)
+            .setHideEmptyPane(true)
+            .setMatchBrackets(false)
+            .setLineWrapping(true)
+            .setIgnoreWhitespace(Whitespace.IGNORE_TRAILING)
+            .setRetainHeader(true)
+            .setSkipDeleted(false)
+            .setSkipUnchanged(true)
+            .setSkipUncommented(false)
+            .build();
+    UserPreferences.DiffPreferencesInfo resProto =
+        DiffPreferencesInfoConverter.toProto(DiffPreferencesInfoConverter.fromProto(originalProto));
+    assertThat(resProto).isEqualTo(originalProto);
+  }
+
+  @Test
+  public void diffPreferencesInfo_emptyJavaToProto() {
+    DiffPreferencesInfo info = new DiffPreferencesInfo();
+    UserPreferences.DiffPreferencesInfo res = DiffPreferencesInfoConverter.toProto(info);
+    assertThat(res).isEqualToDefaultInstance();
+  }
+
+  @Test
+  public void diffPreferencesInfo_defaultJavaToProto() {
+    DiffPreferencesInfo info = DiffPreferencesInfo.defaults();
+    UserPreferences.DiffPreferencesInfo res = DiffPreferencesInfoConverter.toProto(info);
+    assertThat(res)
+        .ignoringFieldAbsence()
+        .isEqualTo(UserPreferences.DiffPreferencesInfo.getDefaultInstance());
+  }
+
+  @Test
+  public void diffPreferencesInfo_emptyProtoToJava() {
+    UserPreferences.DiffPreferencesInfo proto =
+        UserPreferences.DiffPreferencesInfo.getDefaultInstance();
+    DiffPreferencesInfo res = DiffPreferencesInfoConverter.fromProto(proto);
+    assertThat(res).isEqualTo(new DiffPreferencesInfo());
+  }
+
+  @Test
+  public void editPreferencesInfo_compareEnumNames() {
+    // The converter assumes that the enum type equivalents have exactly the same values in both
+    // classes. This test goes over all the enums to verify this assumption.
+    //
+    // If this test breaks, you are likely changing an enum. Please add it to the upstream Java
+    // class first, and on import - also update the proto version and the converter.
+    ImmutableMap<String, EnumDescriptor> protoEnums =
+        getProtoEnum(UserPreferences.EditPreferencesInfo.getDescriptor());
+    ImmutableMap<String, EnumSet<?>> javaEnums = getJavaEnums(EditPreferencesInfo.class);
+    assertThat(protoEnums.keySet()).containsExactlyElementsIn(javaEnums.keySet());
+    for (String enumName : protoEnums.keySet()) {
+      ImmutableList<String> protoEnumValues =
+          protoEnums.get(enumName).getValues().stream()
+              .map(v -> v.getName())
+              .collect(toImmutableList());
+      ImmutableList<String> javaEnumValues =
+          javaEnums.get(enumName).stream().map(Enum::name).collect(toImmutableList());
+      assertThat(protoEnumValues).containsExactlyElementsIn(javaEnumValues);
+    }
+  }
+
+  @Test
+  public void editPreferencesInfo_doubleConversionWithAllFieldsSet() {
+    UserPreferences.EditPreferencesInfo originalProto =
+        UserPreferences.EditPreferencesInfo.newBuilder()
+            .setTabSize(2)
+            .setLineLength(3)
+            .setIndentUnit(5)
+            .setCursorBlinkRate(7)
+            .setHideTopMenu(true)
+            .setShowTabs(false)
+            .setShowWhitespaceErrors(true)
+            .setSyntaxHighlighting(false)
+            .setHideLineNumbers(true)
+            .setMatchBrackets(false)
+            .setLineWrapping(true)
+            .setIndentWithTabs(false)
+            .setAutoCloseBrackets(true)
+            .setShowBase(false)
+            .build();
+    UserPreferences.EditPreferencesInfo resProto =
+        EditPreferencesInfoConverter.toProto(EditPreferencesInfoConverter.fromProto(originalProto));
+    assertThat(resProto).isEqualTo(originalProto);
+  }
+
+  @Test
+  public void editPreferencesInfo_emptyJavaToProto() {
+    EditPreferencesInfo info = new EditPreferencesInfo();
+    UserPreferences.EditPreferencesInfo res = EditPreferencesInfoConverter.toProto(info);
+    assertThat(res).isEqualToDefaultInstance();
+  }
+
+  @Test
+  public void editPreferencesInfo_defaultJavaToProto() {
+    EditPreferencesInfo info = EditPreferencesInfo.defaults();
+    UserPreferences.EditPreferencesInfo res = EditPreferencesInfoConverter.toProto(info);
+    assertThat(res)
+        .ignoringFieldAbsence()
+        .isEqualTo(UserPreferences.EditPreferencesInfo.getDefaultInstance());
+  }
+
+  @Test
+  public void editPreferencesInfo_emptyProtoToJava() {
+    UserPreferences.EditPreferencesInfo proto =
+        UserPreferences.EditPreferencesInfo.getDefaultInstance();
+    EditPreferencesInfo res = EditPreferencesInfoConverter.fromProto(proto);
+    assertThat(res).isEqualTo(new EditPreferencesInfo());
+  }
+
+  private ImmutableMap<String, EnumDescriptor> getProtoEnum(Descriptor d) {
+    return d.getEnumTypes().stream().collect(toImmutableMap(e -> e.getName(), Function.identity()));
+  }
+
+  @SuppressWarnings("unchecked")
+  private ImmutableMap<String, EnumSet<?>> getJavaEnums(Class<?> c) {
+    return stream(c.getDeclaredClasses())
+        .filter(Class::isEnum)
+        .collect(
+            toImmutableMap(Class::getSimpleName, e -> EnumSet.allOf(e.asSubclass(Enum.class))));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/events/EventDeserializerTest.java b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
index 05d6df7..ffdd2a1 100644
--- a/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
+++ b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Supplier;
 import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
@@ -103,6 +104,26 @@
   }
 
   @Test
+  public void customKeyedValuesChangedEvent() {
+    Change change = newChange();
+    CustomKeyedValuesChangedEvent orig = new CustomKeyedValuesChangedEvent(change);
+    orig.change = asChangeAttribute(change);
+    orig.editor = newAccount("editor");
+    orig.added = ImmutableMap.of("key1", "value1");
+    orig.removed = new String[] {"removed"};
+    orig.customKeyedValues = ImmutableMap.of("key2", "value2");
+
+    CustomKeyedValuesChangedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+    assertSameAccount(e.editor, orig.editor);
+    assertThat(e.added).isEqualTo(orig.added);
+    assertThat(e.removed).isEqualTo(orig.removed);
+    assertThat(e.customKeyedValues).isEqualTo(orig.customKeyedValues);
+  }
+
+  @Test
   public void changeAbandonedEvent() {
     Change change = newChange();
     ChangeAbandonedEvent orig = new ChangeAbandonedEvent(change);
diff --git a/javatests/com/google/gerrit/server/events/EventJsonTest.java b/javatests/com/google/gerrit/server/events/EventJsonTest.java
index 3f8519e..ff876e0 100644
--- a/javatests/com/google/gerrit/server/events/EventJsonTest.java
+++ b/javatests/com/google/gerrit/server/events/EventJsonTest.java
@@ -116,6 +116,40 @@
   }
 
   @Test
+  public void batchRefUpdatedEvent() {
+    BatchRefUpdateEvent event = new BatchRefUpdateEvent();
+    String patchsetRefName = "refs/changes/01/1";
+    String metaRefName = "refs/changes/01/meta";
+
+    RefUpdateAttribute refUpdatedAttribute = new RefUpdateAttribute();
+    refUpdatedAttribute.refName = patchsetRefName;
+    RefUpdateAttribute metaRefUpdatedAttribute = new RefUpdateAttribute();
+    metaRefUpdatedAttribute.refName = metaRefName;
+    event.refUpdates =
+        createSupplier(ImmutableList.of(refUpdatedAttribute, metaRefUpdatedAttribute));
+    event.submitter = newAccount("submitter");
+
+    assertThatJsonMap(event)
+        .isEqualTo(
+            ImmutableMap.builder()
+                .put(
+                    "submitter",
+                    ImmutableMap.builder()
+                        .put("name", event.submitter.get().name)
+                        .put("email", event.submitter.get().email)
+                        .put("username", event.submitter.get().username)
+                        .build())
+                .put(
+                    "refUpdates",
+                    ImmutableList.of(
+                        ImmutableMap.of("refName", patchsetRefName),
+                        ImmutableMap.of("refName", metaRefName)))
+                .put("type", "batch-ref-updated")
+                .put("eventCreatedOn", TS1)
+                .build());
+  }
+
+  @Test
   public void patchSetCreatedEvent() {
     Change change = newChange();
     PatchSetCreatedEvent event = new PatchSetCreatedEvent(change);
diff --git a/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java b/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
index 0112f88..05965fb 100644
--- a/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
+++ b/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
@@ -62,7 +62,8 @@
       DeleteZombieCommentsRefs clean =
           new DeleteZombieCommentsRefs(
               new AllUsersName("All-Users"), repoManager, null, (msg) -> {});
-      clean.execute();
+      int deletedDrafts = clean.execute();
+      assertThat(deletedDrafts).isEqualTo(1);
 
       /* Check that ref1 still exists, and ref2 is deleted */
       assertThat(usersRepo.exactRef(ref1.getName())).isNotNull();
@@ -83,7 +84,8 @@
       DeleteZombieCommentsRefs clean =
           new DeleteZombieCommentsRefs(
               new AllUsersName("All-Users"), repoManager, cleanupPercentage, (msg) -> {});
-      clean.execute();
+      int deletedDrafts = clean.execute();
+      assertThat(deletedDrafts).isEqualTo(1);
 
       /* ref1 not deleted, ref2 deleted, ref3 not deleted because of the clean percentage */
       assertThat(usersRepo.getRefDatabase().getRefs()).hasSize(2);
@@ -92,7 +94,8 @@
       assertThat(usersRepo.exactRef(ref3.getName())).isNotNull();
 
       /* Re-execute the cleanup and make sure nothing's changed */
-      clean.execute();
+      deletedDrafts = clean.execute();
+      assertThat(deletedDrafts).isEqualTo(0);
       assertThat(usersRepo.getRefDatabase().getRefs()).hasSize(2);
       assertThat(usersRepo.exactRef(ref1.getName())).isNotNull();
       assertThat(usersRepo.exactRef(ref2.getName())).isNull();
@@ -104,7 +107,8 @@
           new DeleteZombieCommentsRefs(
               new AllUsersName("All-Users"), repoManager, cleanupPercentage, (msg) -> {});
 
-      clean.execute();
+      deletedDrafts = clean.execute();
+      assertThat(deletedDrafts).isEqualTo(1);
 
       /* Now ref3 is deleted */
       assertThat(usersRepo.getRefDatabase().getRefs()).hasSize(1);
@@ -140,7 +144,8 @@
       DeleteZombieCommentsRefs clean =
           new DeleteZombieCommentsRefs(
               new AllUsersName("All-Users"), repoManager, null, (msg) -> {});
-      clean.execute();
+      int deletedDrafts = clean.execute();
+      assertThat(deletedDrafts).isEqualTo(5001);
 
       assertThat(
               usersRepo.getRefDatabase().getRefs().stream()
diff --git a/javatests/com/google/gerrit/server/git/TagSetTest.java b/javatests/com/google/gerrit/server/git/TagSetTest.java
index 4a3c930..089896e 100644
--- a/javatests/com/google/gerrit/server/git/TagSetTest.java
+++ b/javatests/com/google/gerrit/server/git/TagSetTest.java
@@ -31,9 +31,8 @@
 import com.google.gerrit.server.git.TagSet.CachedRef;
 import com.google.gerrit.server.git.TagSet.Tag;
 import com.google.inject.TypeLiteral;
+import com.google.protobuf.ByteString;
 import java.lang.reflect.Type;
-import java.util.Arrays;
-import java.util.BitSet;
 import java.util.Comparator;
 import java.util.HashMap;
 import java.util.Map;
@@ -41,6 +40,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectIdOwnerMap;
 import org.junit.Test;
+import org.roaringbitmap.RoaringBitmap;
 
 public class TagSetTest {
   @Test
@@ -55,10 +55,12 @@
     ObjectIdOwnerMap<Tag> tags = new ObjectIdOwnerMap<>();
     tags.add(
         new Tag(
-            ObjectId.fromString("cccccccccccccccccccccccccccccccccccccccc"), newBitSet(1, 3, 5)));
+            ObjectId.fromString("cccccccccccccccccccccccccccccccccccccccc"),
+            RoaringBitmap.bitmapOf(1, 3, 5)));
     tags.add(
         new Tag(
-            ObjectId.fromString("dddddddddddddddddddddddddddddddddddddddd"), newBitSet(2, 4, 6)));
+            ObjectId.fromString("dddddddddddddddddddddddddddddddddddddddd"),
+            RoaringBitmap.bitmapOf(2, 4, 6)));
     TagSet tagSet = new TagSet(Project.nameKey("project"), refs, tags);
 
     TagSetProto proto = tagSet.toProto();
@@ -91,7 +93,9 @@
                             byteString(
                                 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc,
                                 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc))
-                        .setFlags(byteString(0x2a))
+                        .setFlags(
+                            ByteString.copyFromUtf8(
+                                ":0\000\000\001\000\000\000\000\000\002\000\020\000\000\000\001\000\003\000\005\000"))
                         .build())
                 .addTag(
                     TagProto.newBuilder()
@@ -99,7 +103,9 @@
                             byteString(
                                 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd,
                                 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd))
-                        .setFlags(byteString(0x54))
+                        .setFlags(
+                            ByteString.copyFromUtf8(
+                                ":0\000\000\001\000\000\000\000\000\002\000\020\000\000\000\002\000\004\000\006\000"))
                         .build())
                 .build());
 
@@ -122,8 +128,7 @@
         .extendsClass(new TypeLiteral<AtomicReference<ObjectId>>() {}.getType());
     assertThatSerializedClass(CachedRef.class)
         .hasFields(
-            ImmutableMap.of(
-                "flag", int.class, "value", AtomicReference.class.getTypeParameters()[0]));
+            ImmutableMap.of("flag", int.class, "value", new TypeLiteral<ObjectId>() {}.getType()));
   }
 
   @Test
@@ -132,7 +137,7 @@
     assertThatSerializedClass(Tag.class)
         .hasFields(
             ImmutableMap.<String, Type>builder()
-                .put("refFlags", BitSet.class)
+                .put("refFlags", RoaringBitmap.class)
                 .put("next", ObjectIdOwnerMap.Entry.class)
                 .put("w1", int.class)
                 .put("w2", int.class)
@@ -181,10 +186,4 @@
         .map(Tag::name)
         .collect(ImmutableSortedSet.toImmutableSortedSet(Comparator.naturalOrder()));
   }
-
-  private BitSet newBitSet(int... bits) {
-    BitSet result = new BitSet();
-    Arrays.stream(bits).forEach(result::set);
-    return result;
-  }
 }
diff --git a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
index 6d90309..c4cc878 100644
--- a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
@@ -25,10 +25,12 @@
 import com.google.gerrit.entities.AccountGroupMemberAudit;
 import com.google.gerrit.entities.InternalGroup;
 import com.google.gerrit.server.account.GroupUuid;
-import com.google.gerrit.server.account.externalids.DisabledExternalIdCache;
+import com.google.gerrit.server.account.externalids.storage.notedb.DisabledExternalIdCache;
 import com.google.gerrit.server.notedb.NoteDbUtil;
 import java.time.Instant;
+import java.util.Optional;
 import java.util.Set;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.junit.Before;
 import org.junit.Test;
@@ -41,7 +43,8 @@
   @Before
   public void setUp() throws Exception {
     auditLogReader =
-        new AuditLogReader(allUsersName, new NoteDbUtil(SERVER_ID, new DisabledExternalIdCache()));
+        new AuditLogReader(
+            allUsersName, new NoteDbUtil(SERVER_ID, new DisabledExternalIdCache()), new Config());
   }
 
   @Test
@@ -89,6 +92,67 @@
   }
 
   @Test
+  public void addMemberByUnknownAuthorAndRemoveMemberByKnownAuthor() throws Exception {
+    InternalGroup group = createGroupAsUser(1, "test-group");
+    AccountGroup.UUID uuid = group.getGroupUUID();
+    AccountGroupMemberAudit expAudit1 =
+        createExpMemberAudit(group.getId(), userId, userId, getTipTimestamp(uuid));
+
+    // An unidentified user adds account 100002 to the group.
+    Account.Id id = Account.id(100002);
+    addMembers(
+        uuid,
+        ImmutableSet.of(id),
+        new PersonIdent("Test ident", "random@gerrit"),
+        Optional.of(Instant.ofEpochSecond(1)));
+    // Identified user removes account 100002 from the group.
+    removeMembers(uuid, ImmutableSet.of(id));
+
+    AccountGroupMemberAudit expAudit2 =
+        createExpMemberAudit(
+                group.getId(), id, Account.UNKNOWN_ACCOUNT_ID, Instant.ofEpochSecond(1))
+            .toBuilder()
+            .removed(userId, getTipTimestamp(uuid))
+            .build();
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid))
+        .containsExactly(expAudit1, expAudit2)
+        .inOrder();
+  }
+
+  @Test
+  public void addMemberByUnknownAuthorAndRemoveMemberByKnownAuthor_ignoreUnidentifiedUser()
+      throws Exception {
+    // This test doesn't repeat the previous test, because it doesn't produce the correct result.
+    // Instead this test adds and removes uses by an unidentified user and expects nothing in the
+    // output.
+    Config cfg = new Config();
+    cfg.setBoolean("groups", "auditLog", "ignoreRecordsFromUnidentifiedUsers", true);
+    auditLogReader =
+        new AuditLogReader(
+            allUsersName, new NoteDbUtil(SERVER_ID, new DisabledExternalIdCache()), cfg);
+    InternalGroup group = createGroupAsUser(1, "test-group");
+    AccountGroup.UUID uuid = group.getGroupUUID();
+    AccountGroupMemberAudit expAudit1 =
+        createExpMemberAudit(group.getId(), userId, userId, getTipTimestamp(uuid));
+
+    // An unidentified user adds account 100002 to the group.
+    Account.Id id = Account.id(100002);
+    addMembers(
+        uuid,
+        ImmutableSet.of(id),
+        new PersonIdent("Test ident", "random@gerrit"),
+        Optional.of(Instant.ofEpochSecond(1)));
+    // Identified user removes account 100002 from the group.
+    removeMembers(
+        uuid,
+        ImmutableSet.of(id),
+        new PersonIdent("Test ident", "random@gerrit"),
+        Optional.of(Instant.ofEpochSecond(100)));
+
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid)).containsExactly(expAudit1);
+  }
+
+  @Test
   public void addMultiMembers() throws Exception {
     InternalGroup group = createGroupAsUser(1, "test-group");
     AccountGroup.Id groupId = group.getId();
@@ -268,26 +332,53 @@
   }
 
   private void updateGroup(AccountGroup.UUID uuid, GroupDelta groupDelta) throws Exception {
+    updateGroup(uuid, groupDelta, userIdent);
+  }
+
+  private void updateGroup(AccountGroup.UUID uuid, GroupDelta groupDelta, PersonIdent authorIdent)
+      throws Exception {
     testRefAction(
         () -> {
           GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepo, uuid);
           groupConfig.setGroupDelta(groupDelta, getAuditLogFormatter());
-          groupConfig.commit(createMetaDataUpdate(userIdent));
+          groupConfig.commit(createMetaDataUpdate(authorIdent));
         });
   }
 
   private void addMembers(AccountGroup.UUID groupUuid, Set<Account.Id> ids) throws Exception {
-    GroupDelta groupDelta =
-        GroupDelta.builder().setMemberModification(memberIds -> Sets.union(memberIds, ids)).build();
-    updateGroup(groupUuid, groupDelta);
+    addMembers(groupUuid, ids, userIdent, Optional.empty());
+  }
+
+  private void addMembers(
+      AccountGroup.UUID groupUuid,
+      Set<Account.Id> ids,
+      PersonIdent authorIdent,
+      Optional<Instant> updatedOn)
+      throws Exception {
+    GroupDelta.Builder groupDelta =
+        GroupDelta.builder().setMemberModification(memberIds -> Sets.union(memberIds, ids));
+    if (updatedOn.isPresent()) {
+      groupDelta.setUpdatedOn(updatedOn.get());
+    }
+    updateGroup(groupUuid, groupDelta.build(), authorIdent);
   }
 
   private void removeMembers(AccountGroup.UUID groupUuid, Set<Account.Id> ids) throws Exception {
-    GroupDelta groupDelta =
-        GroupDelta.builder()
-            .setMemberModification(memberIds -> Sets.difference(memberIds, ids))
-            .build();
-    updateGroup(groupUuid, groupDelta);
+    removeMembers(groupUuid, ids, userIdent, Optional.empty());
+  }
+
+  private void removeMembers(
+      AccountGroup.UUID groupUuid,
+      Set<Account.Id> ids,
+      PersonIdent authorIdent,
+      Optional<Instant> updatedOn)
+      throws Exception {
+    GroupDelta.Builder groupDelta =
+        GroupDelta.builder().setMemberModification(memberIds -> Sets.difference(memberIds, ids));
+    if (updatedOn.isPresent()) {
+      groupDelta.setUpdatedOn(updatedOn.get());
+    }
+    updateGroup(groupUuid, groupDelta.build(), authorIdent);
   }
 
   private void addSubgroups(AccountGroup.UUID groupUuid, Set<AccountGroup.UUID> uuids)
diff --git a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
index 13badb5..d816719 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
@@ -78,6 +78,7 @@
             null,
             null,
             null,
+            null,
             indexes,
             null,
             null,
@@ -90,6 +91,7 @@
             null,
             null,
             null,
+            null,
             null));
   }
 
diff --git a/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java b/javatests/com/google/gerrit/server/mail/send/CommentChangeEmailDecoratorTest.java
similarity index 66%
rename from javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
rename to javatests/com/google/gerrit/server/mail/send/CommentChangeEmailDecoratorTest.java
index d7a6282..8eaf2f2 100644
--- a/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/CommentChangeEmailDecoratorTest.java
@@ -18,44 +18,46 @@
 
 import java.util.Collections;
 import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
 
-public class CommentSenderTest {
-  private static class TestSender extends CommentSender {
-    TestSender() {
-      super(null, null, null, null, null, null, null);
-    }
-  }
-
+@RunWith(JUnit4.class)
+public class CommentChangeEmailDecoratorTest {
   // A 100-character long string.
   private static String chars100 = String.join("", Collections.nCopies(25, "abcd"));
 
   @Test
   public void shortMessageNotShortened() {
     String message = "foo bar baz";
-    assertThat(TestSender.getShortenedCommentMessage(message)).isEqualTo(message);
+    assertThat(CommentChangeEmailDecoratorImpl.getShortenedCommentMessage(message))
+        .isEqualTo(message);
 
     message = "foo bar baz.";
-    assertThat(TestSender.getShortenedCommentMessage(message)).isEqualTo(message);
+    assertThat(CommentChangeEmailDecoratorImpl.getShortenedCommentMessage(message))
+        .isEqualTo(message);
   }
 
   @Test
   public void longMessageIsShortened() {
     String message = chars100 + "x";
     String expected = chars100 + " […]";
-    assertThat(TestSender.getShortenedCommentMessage(message)).isEqualTo(expected);
+    assertThat(CommentChangeEmailDecoratorImpl.getShortenedCommentMessage(message))
+        .isEqualTo(expected);
   }
 
   @Test
   public void shortenedToFirstLine() {
     String message = "abc\n" + chars100;
     String expected = "abc […]";
-    assertThat(TestSender.getShortenedCommentMessage(message)).isEqualTo(expected);
+    assertThat(CommentChangeEmailDecoratorImpl.getShortenedCommentMessage(message))
+        .isEqualTo(expected);
   }
 
   @Test
   public void shortenedToFirstSentence() {
     String message = "foo bar baz. " + chars100;
     String expected = "foo bar baz. […]";
-    assertThat(TestSender.getShortenedCommentMessage(message)).isEqualTo(expected);
+    assertThat(CommentChangeEmailDecoratorImpl.getShortenedCommentMessage(message))
+        .isEqualTo(expected);
   }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index 20e441b..96a485a 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static com.google.inject.Scopes.SINGLETON;
 import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.mockito.Mockito.mock;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Account;
@@ -30,6 +31,7 @@
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.ChangeDraftUpdate;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.DefaultRefLogIdentityProvider;
 import com.google.gerrit.server.FanOutExecutor;
@@ -41,8 +43,8 @@
 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.account.externalids.storage.notedb.DisabledExternalIdCache;
 import com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator;
 import com.google.gerrit.server.approval.testing.TestPatchSetApprovalUuidGenerator;
 import com.google.gerrit.server.config.AllUsersName;
@@ -115,6 +117,7 @@
   protected RevWalk rw;
   protected TestRepository<InMemoryRepository> tr;
   protected AssertableExecutorService assertableFanOutExecutor;
+  protected GitReferenceUpdated gitReferenceUpdated;
 
   @Inject protected IdentifiedUser.GenericFactory userFactory;
 
@@ -161,6 +164,7 @@
     ou.setPreferredEmail("other@account.com");
     accountCache.put(ou.build());
     assertableFanOutExecutor = new AssertableExecutorService();
+    gitReferenceUpdated = mock(GitReferenceUpdated.class);
     changeOwnerId = co.id();
     otherUserId = ou.id();
     internalUser = new InternalUser();
@@ -205,7 +209,7 @@
             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(GitReferenceUpdated.class).toInstance(gitReferenceUpdated);
             bind(MetricMaker.class).to(DisabledMetricMaker.class);
             bind(ExecutorService.class)
                 .annotatedWith(FanOutExecutor.class)
@@ -217,6 +221,8 @@
                       throw new UnsupportedOperationException();
                     });
             bind(PatchSetApprovalUuidGenerator.class).to(TestPatchSetApprovalUuidGenerator.class);
+            bind(ChangeDraftUpdate.ChangeDraftUpdateFactory.class)
+                .to(ChangeDraftNotesUpdate.Factory.class);
           }
         });
   }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 225d394..b30fab7 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -65,6 +65,7 @@
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import java.time.Instant;
+import java.util.AbstractMap.SimpleEntry;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -333,6 +334,23 @@
   }
 
   @Test
+  public void serializeCustomKeyedValues() throws Exception {
+    assertRoundTrip(
+        newBuilder()
+            .customKeyedValues(
+                ImmutableList.of(
+                    new SimpleEntry<>("key1", "value1"), new SimpleEntry<>("key2", "value2")))
+            .build(),
+        ChangeNotesStateProto.newBuilder()
+            .setMetaId(SHA_BYTES)
+            .setChangeId(ID.get())
+            .setColumns(colsProto)
+            .putCustomKeyedValues("key1", "value1")
+            .putCustomKeyedValues("key2", "value2")
+            .build());
+  }
+
+  @Test
   public void serializePatchSets() throws Exception {
     PatchSet ps1 =
         PatchSet.builder()
@@ -917,6 +935,9 @@
                 .put("columns", ChangeColumns.class)
                 .put("hashtags", new TypeLiteral<ImmutableSet<String>>() {}.getType())
                 .put(
+                    "customKeyedValues",
+                    new TypeLiteral<ImmutableList<Map.Entry<String, String>>>() {}.getType())
+                .put(
                     "patchSets",
                     new TypeLiteral<ImmutableList<Map.Entry<PatchSet.Id, PatchSet>>>() {}.getType())
                 .put(
@@ -988,6 +1009,7 @@
                 .put("groups", new TypeLiteral<ImmutableList<String>>() {}.getType())
                 .put("pushCertificate", new TypeLiteral<Optional<String>>() {}.getType())
                 .put("description", new TypeLiteral<Optional<String>>() {}.getType())
+                .put("branch", new TypeLiteral<Optional<String>>() {}.getType())
                 .build());
   }
 
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 50ff860..e025ee2 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -29,11 +29,13 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Comparator.comparing;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+import static org.mockito.Mockito.mock;
 
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
@@ -55,8 +57,10 @@
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DraftCommentsReader;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.git.validators.TopicValidator;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -67,6 +71,8 @@
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
+import java.util.TreeMap;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -74,13 +80,21 @@
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
 import org.junit.Test;
 
 public class ChangeNotesTest extends AbstractChangeNotesTest {
-  @Inject private DraftCommentNotes.Factory draftNotesFactory;
-
   @Inject private ChangeNoteJson changeNoteJson;
 
+  @Inject private DraftCommentsReader draftCommentsReader;
+
+  private TopicValidator topicValidator;
+
+  @Before
+  public void setUp() throws Exception {
+    topicValidator = mock(TopicValidator.class);
+  }
+
   @Test
   public void tagChangeMessage() throws Exception {
     String tag = "jenkins";
@@ -228,6 +242,47 @@
   }
 
   @Test
+  public void multipleTargetBranches() throws Exception {
+    Change c = newChange();
+
+    // PS1 with target branch = refs/heads/master
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putApproval(LabelId.VERIFIED, (short) 1);
+    update.commit();
+
+    // PS2 with target branch = refs/heads/foo
+    incrementPatchSet(c);
+    update = newUpdate(c, changeOwner);
+    update.setBranch("refs/heads/foo");
+    update.commit();
+
+    // PS3 with no change
+    incrementPatchSet(c);
+
+    // PS4 with target branch = refs/heads/bar
+    incrementPatchSet(c);
+    update = newUpdate(c, changeOwner);
+    update.setBranch("refs/heads/bar");
+    update.commit();
+
+    // PS5 with no change
+    incrementPatchSet(c);
+
+    ChangeNotes notes = newNotes(c);
+    ImmutableSortedMap<PatchSet.Id, PatchSet> patchSets = notes.getPatchSets();
+    assertThat(patchSets.get(PatchSet.id(c.getId(), 1)).branch())
+        .isEqualTo(Optional.of("refs/heads/master"));
+    assertThat(patchSets.get(PatchSet.id(c.getId(), 2)).branch())
+        .isEqualTo(Optional.of("refs/heads/foo"));
+    assertThat(patchSets.get(PatchSet.id(c.getId(), 3)).branch())
+        .isEqualTo(Optional.of("refs/heads/foo"));
+    assertThat(patchSets.get(PatchSet.id(c.getId(), 4)).branch())
+        .isEqualTo(Optional.of("refs/heads/bar"));
+    assertThat(patchSets.get(PatchSet.id(c.getId(), 5)).branch())
+        .isEqualTo(Optional.of("refs/heads/bar"));
+  }
+
+  @Test
   public void approvalsOnePatchSet() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -1259,7 +1314,7 @@
 
     ChangeUpdate update = newUpdate(c, changeOwner);
     // Make sure unrelevent update does not set mergedOn.
-    update.setTopic("topic");
+    update.setTopic("topic", topicValidator);
     update.commit();
     assertThat(newNotes(c).getMergedOn()).isEmpty();
   }
@@ -1504,6 +1559,78 @@
   }
 
   @Test
+  public void customKeyedValuesCommit() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.addCustomKeyedValue("key1", "value1");
+    update.addCustomKeyedValue("key2", "value2");
+    update.commit();
+    try (RevWalk walk = new RevWalk(repo)) {
+      RevCommit commit = walk.parseCommit(update.getResult());
+      walk.parseBody(commit);
+      assertThat(commit.getFullMessage()).contains("Custom-Keyed-Value: key1=value1\n");
+      assertThat(commit.getFullMessage()).contains("Custom-Keyed-Value: key2=value2\n");
+    }
+  }
+
+  @Test
+  public void customKeyedValuesChangeNotes() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.addCustomKeyedValue("key1", "value\n1");
+    update.addCustomKeyedValue("key2", "value2=value3");
+    update.addCustomKeyedValue("key3", "value3: value4");
+    update.commit();
+
+    TreeMap<String, String> customKeyedValues = new TreeMap<>();
+    customKeyedValues.put("key1", "value 1");
+    customKeyedValues.put("key2", "value2=value3");
+    customKeyedValues.put("key3", "value3: value4");
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getCustomKeyedValues())
+        .isEqualTo(ImmutableSortedMap.copyOfSorted(customKeyedValues));
+  }
+
+  @Test
+  public void customKeyedValuesChangeNotes_Override() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.addCustomKeyedValue("key1", "value1");
+    update.addCustomKeyedValue("key2", "value2");
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.addCustomKeyedValue("key1", "value3");
+    update.commit();
+
+    TreeMap<String, String> customKeyedValues = new TreeMap<>();
+    customKeyedValues.put("key1", "value3");
+    customKeyedValues.put("key2", "value2");
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getCustomKeyedValues())
+        .isEqualTo(ImmutableSortedMap.copyOfSorted(customKeyedValues));
+  }
+
+  @Test
+  public void customKeyedValuesChangeNotes_Deletion() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.addCustomKeyedValue("key1", "value1");
+    update.addCustomKeyedValue("key2", "value2");
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.deleteCustomKeyedValue("key1");
+    update.commit();
+
+    TreeMap<String, String> customKeyedValues = new TreeMap<>();
+    customKeyedValues.put("key2", "value2");
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getCustomKeyedValues())
+        .isEqualTo(ImmutableSortedMap.copyOfSorted(customKeyedValues));
+  }
+
+  @Test
   public void topicChangeNotes() throws Exception {
     Change c = newChange();
 
@@ -1514,14 +1641,14 @@
     // set topic
     String topic = "myTopic";
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setTopic(topic);
+    update.setTopic(topic, topicValidator);
     update.commit();
     notes = newNotes(c);
     assertThat(notes.getChange().getTopic()).isEqualTo(topic);
 
     // clear topic by setting empty string
     update = newUpdate(c, changeOwner);
-    update.setTopic("");
+    update.setTopic("", topicValidator);
     update.commit();
     notes = newNotes(c);
     assertThat(notes.getChange().getTopic()).isNull();
@@ -1529,21 +1656,21 @@
     // set other topic
     topic = "otherTopic";
     update = newUpdate(c, changeOwner);
-    update.setTopic(topic);
+    update.setTopic(topic, topicValidator);
     update.commit();
     notes = newNotes(c);
     assertThat(notes.getChange().getTopic()).isEqualTo(topic);
 
     // clear topic by setting null
     update = newUpdate(c, changeOwner);
-    update.setTopic(null);
+    update.setTopic(null, topicValidator);
     update.commit();
     notes = newNotes(c);
     assertThat(notes.getChange().getTopic()).isNull();
 
     // check invalid topic
     ChangeUpdate failingUpdate = newUpdate(c, changeOwner);
-    assertThrows(ValidationException.class, () -> failingUpdate.setTopic("\""));
+    assertThrows(ValidationException.class, () -> failingUpdate.setTopic("\"", topicValidator));
   }
 
   @Test
@@ -1555,7 +1682,7 @@
 
     // An update doesn't affect the Change-Id
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setTopic("topic"); // Change something to get a new commit.
+    update.setTopic("topic", topicValidator); // Change something to get a new commit.
     update.commit();
     assertThat(notes.getChange().getKey()).isEqualTo(c.getKey());
 
@@ -1584,7 +1711,7 @@
 
     // An update doesn't affect the branch
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setTopic("topic"); // Change something to get a new commit.
+    update.setTopic("topic", topicValidator); // Change something to get a new commit.
     update.commit();
     assertThat(newNotes(c).getChange().getDest()).isEqualTo(expectedBranch);
 
@@ -1605,7 +1732,7 @@
 
     // An update doesn't affect the owner
     ChangeUpdate update = newUpdate(c, otherUser);
-    update.setTopic("topic"); // Change something to get a new commit.
+    update.setTopic("topic", topicValidator); // Change something to get a new commit.
     update.commit();
     assertThat(newNotes(c).getChange().getOwner()).isEqualTo(changeOwner.getAccountId());
   }
@@ -1619,7 +1746,7 @@
 
     // An update doesn't affect the createdOn timestamp.
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setTopic("topic"); // Change something to get a new commit.
+    update.setTopic("topic", topicValidator); // Change something to get a new commit.
     update.commit();
     assertThat(newNotes(c).getChange().getCreatedOn()).isEqualTo(createdOn);
   }
@@ -1634,7 +1761,7 @@
 
     // Various kinds of updates that update the timestamp.
     ChangeUpdate update = newUpdate(c, changeOwner);
-    update.setTopic("topic"); // Change something to get a new commit.
+    update.setTopic("topic", topicValidator); // Change something to get a new commit.
     update.commit();
     Instant ts2 = newNotes(c).getChange().getLastUpdatedOn();
     assertThat(ts2).isGreaterThan(ts1);
@@ -1934,7 +2061,7 @@
     ChangeUpdate update2 = newUpdate(c, otherUser);
     update2.putApproval(LabelId.CODE_REVIEW, (short) 2);
 
-    try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
+    try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project, otherUser)) {
       updateManager.add(update1);
       updateManager.add(update2);
       testRefAction(() -> updateManager.execute());
@@ -1963,7 +2090,7 @@
     Instant time1 = TimeUtil.now();
     PatchSet.Id psId = c.currentPatchSetId();
     RevCommit tipCommit;
-    try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
+    try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project, otherUser)) {
       HumanComment comment1 =
           newComment(
               psId,
@@ -2043,7 +2170,7 @@
     Ref initial2 = repo.exactRef(update2.getRefName());
     assertThat(initial2).isNotNull();
 
-    try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
+    try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project, otherUser)) {
       updateManager.add(update1);
       updateManager.add(update2);
       testRefAction(() -> updateManager.execute());
@@ -2740,8 +2867,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId))
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment1));
+    assertThat(notes.getDraftComments(otherUserId)).containsExactly(comment1);
     assertThat(notes.getHumanComments()).isEmpty();
 
     update = newUpdate(c, otherUser);
@@ -2804,12 +2930,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId))
-        .containsExactlyEntriesIn(
-            ImmutableListMultimap.of(
-                commitId, comment1,
-                commitId, comment2))
-        .inOrder();
+    assertThat(notes.getDraftComments(otherUserId)).containsExactly(comment1, comment2).inOrder();
     assertThat(notes.getHumanComments()).isEmpty();
 
     // Publish first draft.
@@ -2819,8 +2940,7 @@
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId))
-        .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment2));
+    assertThat(notes.getDraftComments(otherUserId)).containsExactly(comment2);
     assertThat(notes.getHumanComments())
         .containsExactlyEntriesIn(ImmutableListMultimap.of(commitId, comment1));
   }
@@ -2875,11 +2995,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId))
-        .containsExactlyEntriesIn(
-            ImmutableListMultimap.of(
-                commitId1, baseComment,
-                commitId2, psComment));
+    assertThat(notes.getDraftComments(otherUserId)).containsExactly(baseComment, psComment);
     assertThat(notes.getHumanComments()).isEmpty();
 
     // Publish both comments.
@@ -3271,8 +3387,7 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId).get(commitId1))
-        .containsExactly(comment1, comment2);
+    assertThat(notes.getDraftComments(otherUserId)).containsExactly(comment1, comment2);
     assertThat(notes.getHumanComments()).isEmpty();
 
     update = newUpdate(c, otherUser);
@@ -3281,7 +3396,7 @@
     update.commit();
 
     notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId).get(commitId1)).containsExactly(comment1);
+    assertThat(notes.getDraftComments(otherUserId)).containsExactly(comment1);
     assertThat(notes.getHumanComments().get(commitId1)).containsExactly(comment2);
   }
 
@@ -3357,20 +3472,24 @@
     // Re-add draft version of comment2 back to draft ref without updating
     // change ref. Simulates the case where deleting the draft failed
     // non-atomically after adding the published comment succeeded.
-    ChangeDraftUpdate draftUpdate = newUpdate(c, otherUser).createDraftUpdateIfNull();
-    draftUpdate.putComment(comment2);
-    try (NoteDbUpdateManager manager = updateManagerFactory.create(c.getProject())) {
-      manager.add(draftUpdate);
-      testRefAction(() -> manager.execute());
+    Optional<ChangeDraftNotesUpdate> draftUpdate =
+        ChangeDraftNotesUpdate.asChangeDraftNotesUpdate(
+            newUpdate(c, otherUser).createDraftUpdateIfNull());
+    if (draftUpdate.isPresent()) {
+      draftUpdate.get().putDraftComment(comment2);
+      try (NoteDbUpdateManager manager = updateManagerFactory.create(c.getProject(), otherUser)) {
+        manager.add(draftUpdate.get());
+        testRefAction(() -> manager.execute());
+      }
     }
 
     // Looking at drafts directly shows the zombie comment.
-    DraftCommentNotes draftNotes = draftNotesFactory.create(c.getId(), otherUserId);
-    assertThat(draftNotes.load().getComments().get(commitId1)).containsExactly(comment1, comment2);
+    assertThat(draftCommentsReader.getDraftsByChangeAndDraftAuthor(c.getId(), otherUserId))
+        .containsExactly(comment1, comment2);
 
     // Zombie comment is filtered out of drafts via ChangeNotes.
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getDraftComments(otherUserId).get(commitId1)).containsExactly(comment1);
+    assertThat(notes.getDraftComments(otherUserId)).containsExactly(comment1);
     assertThat(notes.getHumanComments().get(commitId1)).containsExactly(comment2);
 
     update = newUpdate(c, otherUser);
@@ -3422,7 +3541,7 @@
             false);
     update2.putComment(HumanComment.Status.PUBLISHED, comment2);
 
-    try (NoteDbUpdateManager manager = updateManagerFactory.create(project)) {
+    try (NoteDbUpdateManager manager = updateManagerFactory.create(project, otherUser)) {
       manager.add(update1);
       manager.add(update2);
       testRefAction(() -> manager.execute());
diff --git a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
index d13ccdd..064cd89 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
@@ -23,6 +23,7 @@
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
 import static java.util.Objects.requireNonNull;
+import static org.mockito.Mockito.mock;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -40,6 +41,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.git.validators.TopicValidator;
 import com.google.gerrit.server.notedb.ChangeNoteUtil.AttentionStatusInNoteDb;
 import com.google.gerrit.server.notedb.CommitRewriter.BackfillResult;
 import com.google.gerrit.server.notedb.CommitRewriter.CommitDiff;
@@ -74,10 +76,14 @@
   private @Inject CommitRewriter rewriter;
   @Inject private ChangeNoteUtil changeNoteUtil;
 
+  private TopicValidator topicValidator;
+
   private static final Gson gson = OutputFormat.JSON_COMPACT.newGson();
 
   @Before
-  public void setUp() throws Exception {}
+  public void setUp() throws Exception {
+    topicValidator = mock(TopicValidator.class);
+  }
 
   @After
   public void cleanUp() throws Exception {
@@ -1500,7 +1506,7 @@
     ChangeUpdate invalidMergedMessageUpdate = newUpdate(c, changeOwner);
     invalidMergedMessageUpdate.setChangeMessage(
         "Change has been successfully merged by " + changeOwner.getName());
-    invalidMergedMessageUpdate.setTopic("");
+    invalidMergedMessageUpdate.setTopic("", topicValidator);
 
     commitsToFix.add(invalidMergedMessageUpdate.commit());
     ChangeUpdate invalidCherryPickedMessageUpdate = newUpdate(c, changeOwner);
diff --git a/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java b/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
index 31b1db0..e224bdb 100644
--- a/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/DraftCommentNotesTest.java
@@ -15,13 +15,19 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
 
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.util.time.TimeUtil;
+import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
+import org.mockito.ArgumentCaptor;
 
 public class DraftCommentNotesTest extends AbstractChangeNotesTest {
 
@@ -80,6 +86,23 @@
     assertableFanOutExecutor.assertInteractions(0);
   }
 
+  @Test
+  public void createAndPublishCommentInOneAction_firesRefUpdatedDeletion() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, otherUser);
+    update.setPatchSetId(c.currentPatchSetId());
+    update.putComment(HumanComment.Status.PUBLISHED, comment(c.currentPatchSetId()));
+    update.commit();
+
+    assertThat(newNotes(c).getDraftComments(otherUserId)).isEmpty();
+
+    ArgumentCaptor<AccountState> accountStateCaptor = ArgumentCaptor.forClass(AccountState.class);
+    verify(gitReferenceUpdated)
+        .fire(any(AllUsersName.class), any(BatchRefUpdate.class), accountStateCaptor.capture());
+
+    assertThat(accountStateCaptor.getValue()).isEqualTo(otherUser.state());
+  }
+
   private HumanComment comment(PatchSet.Id psId) {
     return newComment(
         psId,
diff --git a/javatests/com/google/gerrit/server/patch/DiffValidatorsTest.java b/javatests/com/google/gerrit/server/patch/DiffValidatorsTest.java
new file mode 100644
index 0000000..9f697fd
--- /dev/null
+++ b/javatests/com/google/gerrit/server/patch/DiffValidatorsTest.java
@@ -0,0 +1,106 @@
+// 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.patch;
+
+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.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Patch.FileMode;
+import com.google.gerrit.entities.Patch.PatchType;
+import com.google.gerrit.server.git.LargeObjectException;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Test class for {@link DiffValidators}. */
+public class DiffValidatorsTest {
+  @Inject private DiffValidators diffValidators;
+  @Inject private DiffFileSizeValidator diffFileSizeValidator;
+
+  @Before
+  public void setUpInjector() throws Exception {
+    Injector injector = Guice.createInjector(new InMemoryModule());
+    injector.injectMembers(this);
+  }
+
+  @Test
+  public void fileSizeExceeded_enforcedIfConfigIsSet_fileSizeExceeded() {
+    diffFileSizeValidator.setMaxFileSize(1000);
+    int largeSize = 100000000;
+    FileDiffOutput fileDiff = createFakeFileDiffOutput(largeSize);
+    Exception thrown =
+        assertThrows(LargeObjectException.class, () -> diffValidators.validate(fileDiff));
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "File size for file f.txt exceeded the max file size threshold."
+                    + " Threshold = %d MiB, Actual size = %d MiB",
+                diffFileSizeValidator.maxFileSize, largeSize));
+  }
+
+  @Test
+  public void fileSizeExceeded_enforcedIfConfigIsSet_fileSizeNotExceeded() throws Exception {
+    diffFileSizeValidator.setMaxFileSize(1000);
+    int largeSize = 50;
+    FileDiffOutput fileDiff = createFakeFileDiffOutput(largeSize);
+    diffValidators.validate(fileDiff);
+  }
+
+  @Test
+  public void fileSizeExceeded_notEnforcedIfConfigNotSet() throws Exception {
+    int largeSize = 100000000;
+    FileDiffOutput fileDiff = createFakeFileDiffOutput(largeSize);
+    diffValidators.validate(fileDiff);
+  }
+
+  @Test
+  public void binaryFileSizeExceeded_notCheckedForFileSize() throws Exception {
+    diffFileSizeValidator.setMaxFileSize(1000);
+    int largeSize = 100000000;
+    FileDiffOutput fileDiff = createFakeFileDiffOutput(largeSize, PatchType.BINARY);
+    diffValidators.validate(fileDiff);
+  }
+
+  private FileDiffOutput createFakeFileDiffOutput(int largeSize) {
+    return createFakeFileDiffOutput(largeSize, PatchType.UNIFIED);
+  }
+
+  private FileDiffOutput createFakeFileDiffOutput(int largeSize, PatchType patchType) {
+    return FileDiffOutput.builder()
+        .oldCommitId(ObjectId.zeroId())
+        .newCommitId(ObjectId.zeroId())
+        .comparisonType(ComparisonType.againstRoot())
+        .changeType(ChangeType.ADDED)
+        .patchType(Optional.of(patchType))
+        .oldPath(Optional.empty())
+        .newPath(Optional.of("f.txt"))
+        .oldMode(Optional.empty())
+        .newMode(Optional.of(FileMode.REGULAR_FILE))
+        .headerLines(ImmutableList.of())
+        .edits(ImmutableList.of())
+        .size(largeSize)
+        .sizeDelta(largeSize)
+        .build();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index 414555b..2f9a428 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -705,7 +705,7 @@
     for (AccountExternalIdInfo info : externalIdInfos) {
       Optional<ExternalId> extId = externalIds.get(externalIdKeyFactory.parse(info.identity));
       assertThat(extId).isPresent();
-      blobs.add(new ByteArrayWrapper(extId.get().toByteArray()));
+      blobs.add(new ByteArrayWrapper(AccountField.serializeExternalId(extId.get())));
     }
 
     // Some installations do not store EXTERNAL_ID_STATE_SPEC
diff --git a/javatests/com/google/gerrit/server/query/account/BUILD b/javatests/com/google/gerrit/server/query/account/BUILD
index c781d8b..2c31aef 100644
--- a/javatests/com/google/gerrit/server/query/account/BUILD
+++ b/javatests/com/google/gerrit/server/query/account/BUILD
@@ -16,6 +16,7 @@
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/server",
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 5debe1d..28c7be9 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -74,6 +74,7 @@
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.Changes.QueryRequest;
+import com.google.gerrit.extensions.api.changes.CustomKeyedValuesInput;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -106,6 +107,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountManager;
@@ -131,7 +133,6 @@
 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;
 import com.google.gerrit.server.schema.SchemaCreator;
@@ -478,7 +479,7 @@
     assertQuery("is:open", change2, change1);
     assertQuery("is:private");
 
-    gApi.changes().id(change1.getChangeId()).setPrivate(true, null);
+    getChangeApi(change1).setPrivate(true, null);
 
     // Change1 is private, but should be still visible to its owner.
     assertQuery("is:open", change1, change2);
@@ -498,11 +499,11 @@
     assertQuery("is:open", change1);
     assertQuery("is:wip");
 
-    gApi.changes().id(change1.getChangeId()).setWorkInProgress();
+    getChangeApi(change1).setWorkInProgress();
 
     assertQuery("is:wip", change1);
 
-    gApi.changes().id(change1.getChangeId()).setReadyForReview();
+    getChangeApi(change1).setReadyForReview();
 
     assertQuery("is:wip");
   }
@@ -516,11 +517,11 @@
     assertQuery("is:wip", change1);
     assertQuery("reviewer:" + user1);
 
-    gApi.changes().id(change1.getChangeId()).setReadyForReview();
+    getChangeApi(change1).setReadyForReview();
     assertQuery("is:wip");
     assertQuery("reviewer:" + user1);
 
-    gApi.changes().id(change1.getChangeId()).setWorkInProgress();
+    getChangeApi(change1).setWorkInProgress();
     assertQuery("is:wip", change1);
     assertQuery("reviewer:" + user1);
   }
@@ -532,10 +533,10 @@
 
     assertQuery("is:started");
 
-    gApi.changes().id(change1.getChangeId()).setReadyForReview();
+    getChangeApi(change1).setReadyForReview();
     assertQuery("is:started", change1);
 
-    gApi.changes().id(change1.getChangeId()).setWorkInProgress();
+    getChangeApi(change1).setWorkInProgress();
     assertQuery("is:started", change1);
   }
 
@@ -579,7 +580,7 @@
             .reviewer(user2.toString(), ReviewerState.CC, false)
             .reviewer(email1)
             .reviewer(email2, ReviewerState.CC, false);
-    gApi.changes().id(change1.getId().get()).current().review(in);
+    getChangeApi(change1).current().review(in);
 
     List<ChangeInfo> changeInfos =
         assertQuery(newQuery("is:wip").withOption(DETAILED_LABELS), change1);
@@ -596,12 +597,12 @@
     // Pending reviewers may also be presented in the REMOVED state. Toggle the
     // change to ready and then back to WIP and remove reviewers to produce.
     assertThat(pendingReviewers.get(ReviewerState.REMOVED)).isNull();
-    gApi.changes().id(change1.getId().get()).setReadyForReview();
-    gApi.changes().id(change1.getId().get()).setWorkInProgress();
-    gApi.changes().id(change1.getId().get()).reviewer(user1.toString()).remove();
-    gApi.changes().id(change1.getId().get()).reviewer(user2.toString()).remove();
-    gApi.changes().id(change1.getId().get()).reviewer(email1).remove();
-    gApi.changes().id(change1.getId().get()).reviewer(email2).remove();
+    getChangeApi(change1).setReadyForReview();
+    getChangeApi(change1).setWorkInProgress();
+    getChangeApi(change1).reviewer(user1.toString()).remove();
+    getChangeApi(change1).reviewer(user2.toString()).remove();
+    getChangeApi(change1).reviewer(email1).remove();
+    getChangeApi(change1).reviewer(email2).remove();
 
     changeInfos = assertQuery(newQuery("is:wip").withOption(DETAILED_LABELS), change1);
     assertThat(changeInfos).isNotEmpty();
@@ -793,8 +794,8 @@
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     Change change2 = insert("repo", newChange(repo), user2);
     Change change3 = insert("repo", newChange(repo), user2);
-    gApi.changes().id(change3.getId().get()).current().review(ReviewInput.approve());
-    gApi.changes().id(change3.getId().get()).current().submit();
+    getChangeApi(change3).current().review(ReviewInput.approve());
+    getChangeApi(change3).current().submit();
 
     assertQuery("ownerin:Administrators", change1);
     assertQuery("ownerin:\"Registered Users\"", change3, change2, change1);
@@ -1106,40 +1107,80 @@
   }
 
   @Test
-  public void byMessageExact() throws Exception {
-    repo = createAndOpenProject("repo");
-    RevCommit commit1 = repo.parseBody(repo.commit().message("one").create());
-    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
-    RevCommit commit2 = repo.parseBody(repo.commit().message("two").create());
-    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);
+  public void byMessageExact_byAlias_d() throws Exception {
+    byMessageExact("d:", "d_repo");
   }
 
   @Test
-  public void byMessageRegEx() throws Exception {
+  public void byMessageExact_byAlias_description() throws Exception {
+    byMessageExact("description:", "description_repo");
+  }
+
+  @Test
+  public void byMessageExact_byAlias_m() throws Exception {
+    byMessageExact("m:", "m_repo");
+  }
+
+  @Test
+  public void byMessageExact_byMainOperator() throws Exception {
+    byMessageExact("message:", "message_repo");
+  }
+
+  private void byMessageExact(String searchOperator, String projectName) throws Exception {
+    repo = createAndOpenProject(projectName);
+    RevCommit commit1 = repo.parseBody(repo.commit().message("one").create());
+    Change change1 = insert(projectName, newChangeForCommit(repo, commit1));
+    RevCommit commit2 = repo.parseBody(repo.commit().message("two").create());
+    Change change2 = insert(projectName, newChangeForCommit(repo, commit2));
+    RevCommit commit3 = repo.parseBody(repo.commit().message("A great \"fix\" to my bug").create());
+    Change change3 = insert(projectName, newChangeForCommit(repo, commit3));
+
+    assertQuery(searchOperator + "foo");
+    assertQuery(searchOperator + "one", change1);
+    assertQuery(searchOperator + "two", change2);
+    assertQuery(searchOperator + "\"great \\\"fix\\\" to\"", change3);
+  }
+
+  @Test
+  public void byMessageRegEx_byAlias_d() throws Exception {
+    byMessageRegEx("d:", "d_repo");
+  }
+
+  @Test
+  public void byMessageRegEx_byAlias_description() throws Exception {
+    byMessageRegEx("description:", "description_repo");
+  }
+
+  @Test
+  public void byMessageRegEx_byAlias_m() throws Exception {
+    byMessageRegEx("m:", "m_repo");
+  }
+
+  @Test
+  public void byMessageRegEx_byMainOperator() throws Exception {
+    byMessageRegEx("message:", "message_repo");
+  }
+
+  private void byMessageRegEx(String searchOperator, String projectName) throws Exception {
     assume().that(getSchema().hasField(ChangeField.COMMIT_MESSAGE_EXACT)).isTrue();
-    repo = createAndOpenProject("repo");
+    repo = createAndOpenProject(projectName);
     RevCommit commit1 = repo.parseBody(repo.commit().message("aaaabcc").create());
-    Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+    Change change1 = insert(projectName, newChangeForCommit(repo, commit1));
     RevCommit commit2 = repo.parseBody(repo.commit().message("aaaacc").create());
-    Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+    Change change2 = insert(projectName, newChangeForCommit(repo, commit2));
     RevCommit commit3 = repo.parseBody(repo.commit().message("Title\n\nHELLO WORLD").create());
-    Change change3 = insert("repo", newChangeForCommit(repo, commit3));
+    Change change3 = insert(projectName, newChangeForCommit(repo, commit3));
     RevCommit commit4 =
         repo.parseBody(repo.commit().message("Title\n\nfoobar hello WORLD").create());
-    Change change4 = insert("repo", newChangeForCommit(repo, commit4));
+    Change change4 = insert(projectName, newChangeForCommit(repo, commit4));
 
-    assertQuery("message:\"^aaaa(b|c)*\"", change2, change1);
-    assertQuery("message:\"^aaaa(c)*c.*\"", change2);
-    assertQuery("message:\"^.*HELLO WORLD.*\"", change3);
+    assertQuery(searchOperator + "\"^aaaa(b|c)*\"", change2, change1);
+    assertQuery(searchOperator + "\"^aaaa(c)*c.*\"", change2);
+    assertQuery(searchOperator + "\"^.*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);
+        searchOperator + "\"^.*(H|h)(E|e)(L|l)(L|l)(O|o) (W|w)(O|o)(R|r)(L|l)(D|d).*\"",
+        change4,
+        change3);
   }
 
   @Test
@@ -1299,30 +1340,27 @@
     ChangeInserter ins6 = newChange(repo);
 
     Change reviewMinus2Change = insert("repo", ins);
-    gApi.changes().id(reviewMinus2Change.getId().get()).current().review(ReviewInput.reject());
+    getChangeApi(reviewMinus2Change).current().review(ReviewInput.reject());
 
     Change reviewMinus1Change = insert("repo", ins2);
-    gApi.changes().id(reviewMinus1Change.getId().get()).current().review(ReviewInput.dislike());
+    getChangeApi(reviewMinus1Change).current().review(ReviewInput.dislike());
 
     Change noLabelChange = insert("repo", ins3);
 
     Change reviewPlus1Change = insert("repo", ins4);
-    gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
+    getChangeApi(reviewPlus1Change).current().review(ReviewInput.recommend());
 
     Change reviewTwoPlus1Change = insert("repo", ins5);
-    gApi.changes().id(reviewTwoPlus1Change.getId().get()).current().review(ReviewInput.recommend());
+    getChangeApi(reviewTwoPlus1Change).current().review(ReviewInput.recommend());
     requestContext.setContext(newRequestContext(createAccount("user1")));
-    gApi.changes().id(reviewTwoPlus1Change.getId().get()).current().review(ReviewInput.recommend());
+    getChangeApi(reviewTwoPlus1Change).current().review(ReviewInput.recommend());
     requestContext.setContext(newRequestContext(userId));
 
     Change reviewPlus2Change = insert("repo", ins6);
-    gApi.changes().id(reviewPlus2Change.getId().get()).current().review(ReviewInput.approve());
+    getChangeApi(reviewPlus2Change).current().review(ReviewInput.approve());
 
     Map<String, Short> m =
-        gApi.changes()
-            .id(reviewPlus1Change.getId().get())
-            .reviewer(user.getAccountId().toString())
-            .votes();
+        getChangeApi(reviewPlus1Change).reviewer(user.getAccountId().toString()).votes();
     assertThat(m).hasSize(1);
     assertThat(m).containsEntry("Code-Review", Short.valueOf((short) 1));
 
@@ -1492,25 +1530,25 @@
 
     // CR+1
     Change reviewCRplus1 = insert(project.get(), ins);
-    gApi.changes().id(reviewCRplus1.getId().get()).current().review(ReviewInput.recommend());
+    getChangeApi(reviewCRplus1).current().review(ReviewInput.recommend());
 
     // CR+2
     Change reviewCRplus2 = insert(project.get(), ins2);
-    gApi.changes().id(reviewCRplus2.getId().get()).current().review(ReviewInput.approve());
+    getChangeApi(reviewCRplus2).current().review(ReviewInput.approve());
 
     // CR+1 VR+1
     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);
+    getChangeApi(reviewCRplus1VRplus1).current().review(ReviewInput.recommend());
+    getChangeApi(reviewCRplus1VRplus1).current().review(reviewVerified);
 
     // CR+2 VR+1
     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);
+    getChangeApi(reviewCRplus2VRplus1).current().review(ReviewInput.approve());
+    getChangeApi(reviewCRplus2VRplus1).current().review(reviewVerified);
 
     // VR+1
     Change reviewVRplus1 = insert(project.get(), ins5);
-    gApi.changes().id(reviewVRplus1.getId().get()).current().review(reviewVerified);
+    getChangeApi(reviewVRplus1).current().review(reviewVerified);
 
     assertQuery("label:Code-Review=+1", reviewCRplus1VRplus1, reviewCRplus1);
     assertQuery(
@@ -1536,7 +1574,7 @@
 
     // post a review with user1
     requestContext.setContext(newRequestContext(user1));
-    gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
+    getChangeApi(reviewPlus1Change).current().review(ReviewInput.recommend());
 
     assertQuery("label:Code-Review=+1,user=" + user1, reviewPlus1Change);
     assertQuery("label:Code-Review=+1,owner");
@@ -1552,12 +1590,12 @@
     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());
+    getChangeApi(reviewPlus1Change).current().review(ReviewInput.recommend());
     assertQuery("label:Code-Review=+1,user=non_uploader");
 
     // add a +1 vote with "user1". Query will match since voter is a non-uploader.
     requestContext.setContext(newRequestContext(user1));
-    gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
+    getChangeApi(reviewPlus1Change).current().review(ReviewInput.recommend());
     assertQuery("label:Code-Review=+1,user=non_uploader", reviewPlus1Change);
     assertQuery("label:Code-Review=+1,non_uploader", reviewPlus1Change);
   }
@@ -1602,10 +1640,7 @@
 
     // post a review with user1
     requestContext.setContext(newRequestContext(user1));
-    gApi.changes()
-        .id(change1.getId().get())
-        .current()
-        .review(new ReviewInput().label("Code-Review", 1));
+    getChangeApi(change1).current().review(new ReviewInput().label("Code-Review", 1));
 
     // verify that query with user1 will return results.
     requestContext.setContext(newRequestContext(userId));
@@ -1650,15 +1685,9 @@
 
     // 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));
+    getChangeApi(change1).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));
+    getChangeApi(change2).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);
@@ -1773,10 +1802,7 @@
     }
 
     for (int i : ImmutableList.of(2, 0, 1, 4, 3)) {
-      gApi.changes()
-          .id(changes.get(i).getId().get())
-          .current()
-          .review(new ReviewInput().message("modifying " + i));
+      getChangeApi(changes.get(i)).current().review(new ReviewInput().message("modifying " + i));
     }
 
     assertQuery(
@@ -1799,7 +1825,7 @@
     assertThat(lastUpdatedMs(change1)).isLessThan(lastUpdatedMs(change2));
     assertQuery("status:new", change2, change1);
 
-    gApi.changes().id(change1.getId().get()).topic("new-topic");
+    getChangeApi(change1).topic("new-topic");
     change1 = notesFactory.create(change1.getProject(), change1.getId()).getChange();
 
     assertThat(lastUpdatedMs(change1)).isGreaterThan(lastUpdatedMs(change2));
@@ -2113,15 +2139,13 @@
     commentInput.line = 1;
     commentInput.message = "inline";
     input.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(commentInput));
-    gApi.changes().id(change.getId().get()).current().review(input);
+    getChangeApi(change).current().review(input);
 
-    Map<String, List<CommentInfo>> comments =
-        gApi.changes().id(change.getId().get()).current().comments();
+    Map<String, List<CommentInfo>> comments = getChangeApi(change).current().comments();
     assertThat(comments).hasSize(1);
     CommentInfo comment = Iterables.getOnlyElement(comments.get(Patch.COMMIT_MSG));
     assertThat(comment.message).isEqualTo(commentInput.message);
-    ChangeMessageInfo lastMsg =
-        Iterables.getLast(gApi.changes().id(change.getId().get()).get().messages, null);
+    ChangeMessageInfo lastMsg = Iterables.getLast(getChangeApi(change).get().messages, null);
     assertThat(lastMsg.message).isEqualTo("Patch Set 1:\n\n(1 comment)\n\n" + input.message);
 
     assertQuery("comment:foo");
@@ -2493,15 +2517,15 @@
     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");
+    addHashtags(change1, "foo", "aaa-bbb-ccc");
+    addHashtags(change2, "foo", "bar", "a tag", "ACamelCaseTag");
     return ImmutableList.of(change1, change2);
   }
 
-  private void addHashtags(Change.Id changeId, String... hashtags) throws Exception {
+  private void addHashtags(Change change, String... hashtags) throws Exception {
     HashtagsInput in = new HashtagsInput();
     in.add = ImmutableSet.copyOf(hashtags);
-    gApi.changes().id(changeId.get()).setHashtags(in);
+    getChangeApi(change).setHashtags(in);
   }
 
   @Test
@@ -2542,10 +2566,10 @@
     Change change1 = insert("repo", newChange(repo));
     Change change2 = insert("repo", newChange(repo));
     Change change3 = insert("repo", newChange(repo));
-    addHashtags(change1.getId(), "feature1");
-    addHashtags(change1.getId(), "trending");
-    addHashtags(change2.getId(), "Cherrypick-feature1");
-    addHashtags(change3.getId(), "feature1-fixup");
+    addHashtags(change1, "feature1");
+    addHashtags(change1, "trending");
+    addHashtags(change2, "Cherrypick-feature1");
+    addHashtags(change3, "feature1-fixup");
 
     assertQuery("inhashtag:^feature1.*", change3, change1);
     assertQuery("inhashtag:{^.*feature1$}", change2, change1);
@@ -2569,7 +2593,7 @@
     ReviewInput ri4 = new ReviewInput();
     ri4.message = "toplevel";
     ri4.labels = ImmutableMap.of("Code-Review", (short) 1);
-    gApi.changes().id(change4.getId().get()).current().review(ri4);
+    getChangeApi(change4).current().review(ri4);
 
     ChangeInserter ins5 = newChangeWithTopic(repo, "feature5");
     Change change5 = insert("repo", ins5);
@@ -2705,7 +2729,7 @@
     Change change1 = insert("repo", newChange(repo));
     Change change2 = insert("repo", newChange(repo));
 
-    gApi.changes().id(change2.getChangeId()).setPrivate(true, "private");
+    getChangeApi(change2).setPrivate(true, "private");
 
     String q = "project:repo";
     assertQuery(q + " visibleto:self", change2, change1);
@@ -2731,11 +2755,11 @@
     comment.line = 1;
     comment.message = "inline";
     input.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment));
-    gApi.changes().id(change1.getId().get()).current().review(input);
+    getChangeApi(change1).current().review(input);
 
     input = new ReviewInput();
     input.message = "toplevel";
-    gApi.changes().id(change2.getId().get()).current().review(input);
+    getChangeApi(change2).current().review(input);
 
     assertQuery("commentby:" + userId.get(), change2, change1);
     assertQuery("commentby:" + user2);
@@ -2753,7 +2777,7 @@
       // 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);
+      getChangeApi(change).setHashtags(hashtag);
 
       assertQuery("rule:FakeSubmitRule", change);
       assertQuery("rule:FakeSubmitRule=OK", change);
@@ -2783,13 +2807,13 @@
     in.line = 1;
     in.message = "nit: trailing whitespace";
     in.path = Patch.COMMIT_MSG;
-    gApi.changes().id(change1.getId().get()).current().createDraft(in);
+    getChangeApi(change1).current().createDraft(in);
 
     in = new DraftInput();
     in.line = 2;
     in.message = "nit: point in the end of the statement";
     in.path = Patch.COMMIT_MSG;
-    gApi.changes().id(change2.getId().get()).current().createDraft(in);
+    getChangeApi(change2).current().createDraft(in);
 
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
@@ -2815,7 +2839,7 @@
     in.line = 1;
     in.message = "nit: trailing whitespace";
     in.path = Patch.COMMIT_MSG;
-    gApi.changes().id(id.get()).current().createDraft(in);
+    getChangeApi(change).current().createDraft(in);
 
     assertQuery("has:draft", change);
     assertQuery("commentby:" + userId);
@@ -2827,7 +2851,7 @@
 
       ReviewInput rin = ReviewInput.dislike();
       rin.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
-      gApi.changes().id(id.get()).current().review(rin);
+      getChangeApi(change).current().review(rin);
 
       assertQuery("has:draft");
       assertQuery("commentby:" + userId, change);
@@ -2857,10 +2881,7 @@
       in.line = 1;
       in.message = "nit: trailing whitespace";
       in.path = Patch.COMMIT_MSG;
-      gApi.changes()
-          .id(changesWithDrafts[changesWithDrafts.length - 1 - i].getId().get())
-          .current()
-          .createDraft(in);
+      getChangeApi(changesWithDrafts[changesWithDrafts.length - 1 - i]).current().createDraft(in);
     }
     assertQuery("has:draft", changesWithDrafts);
 
@@ -2983,7 +3004,7 @@
     comment.line = 1;
     comment.message = "inline";
     input.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment));
-    gApi.changes().id(change2.getId().get()).current().review(input);
+    getChangeApi(change2).current().review(input);
 
     assertQuery("from:" + userId.get(), change2, change1);
     assertQuery("from:" + user2, change2);
@@ -3030,15 +3051,15 @@
     assertQuery("conflicts:" + change2.getId().get(), change1);
     assertQuery("is:mergeable", change2, change1);
 
-    gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(change1.getChangeId()).current().submit();
+    getChangeApi(change1).current().review(ReviewInput.approve());
+    getChangeApi(change1).current().submit();
 
     // If a change gets submitted, the remaining open changes get reindexed asynchronously to update
     // their mergeability information. If the further assertions in this test are done before the
     // asynchronous reindex completed they fail because the mergeability information in the index
     // was not updated yet. To avoid this flakiness indexing mergeable is switched off for the
     // tests and we index change2 synchronously here.
-    gApi.changes().id(change2.getChangeId()).index();
+    getChangeApi(change2).index();
 
     assertQuery("status:open conflicts:" + change2.getId().get());
     assertQuery("status:open is:mergeable");
@@ -3090,22 +3111,19 @@
     Change change2 = insert("repo", newChange(repo));
     Change change3 = insert("repo", newChange(repo));
 
-    gApi.changes().id(change1.getId().get()).current().review(new ReviewInput().message("comment"));
+    getChangeApi(change1).current().review(new ReviewInput().message("comment"));
 
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     requestContext.setContext(newRequestContext(user2));
 
-    gApi.changes().id(change2.getId().get()).current().review(new ReviewInput().message("comment"));
+    getChangeApi(change2).current().review(new ReviewInput().message("comment"));
 
     PatchSet.Id ps3_1 = change3.currentPatchSetId();
     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()
-        .id(change3.getId().get())
-        .revision(ps3_1.get())
-        .review(new ReviewInput().message("comment"));
+    getChangeApi(change3).revision(ps3_1.get()).review(new ReviewInput().message("comment"));
 
     List<ChangeInfo> actual;
     actual = assertQuery(newQuery("is:reviewed").withOption(REVIEWED), change3, change2);
@@ -3135,16 +3153,16 @@
     ReviewerInput rin = new ReviewerInput();
     rin.reviewer = user1.toString();
     rin.state = ReviewerState.REVIEWER;
-    gApi.changes().id(change1.getId().get()).addReviewer(rin);
+    getChangeApi(change1).addReviewer(rin);
 
     rin = new ReviewerInput();
     rin.reviewer = user1.toString();
     rin.state = ReviewerState.CC;
-    gApi.changes().id(change2.getId().get()).addReviewer(rin);
+    getChangeApi(change2).addReviewer(rin);
 
     assertQuery("is:reviewer");
     assertQuery("reviewer:self");
-    gApi.changes().id(change3.getChangeId()).current().review(ReviewInput.recommend());
+    getChangeApi(change3).current().review(ReviewInput.recommend());
     assertQuery("is:reviewer", change3);
     assertQuery("reviewer:self", change3);
 
@@ -3169,7 +3187,7 @@
     assertQuery("-status:reviewed", change2, change1);
 
     requestContext.setContext(newRequestContext(otherUser));
-    gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.recommend());
+    getChangeApi(change1).current().review(ReviewInput.recommend());
 
     assertQuery("is:reviewed", change1);
     assertQuery("status:reviewed", change1);
@@ -3194,17 +3212,17 @@
     ReviewerInput rin = new ReviewerInput();
     rin.reviewer = user1.toString();
     rin.state = ReviewerState.REVIEWER;
-    gApi.changes().id(change1.getId().get()).addReviewer(rin);
+    getChangeApi(change1).addReviewer(rin);
 
     rin = new ReviewerInput();
     rin.reviewer = user2.toString();
     rin.state = ReviewerState.REVIEWER;
-    gApi.changes().id(change2.getId().get()).addReviewer(rin);
+    getChangeApi(change2).addReviewer(rin);
 
     rin = new ReviewerInput();
     rin.reviewer = user3.toString();
     rin.state = ReviewerState.CC;
-    gApi.changes().id(change3.getId().get()).addReviewer(rin);
+    getChangeApi(change3).addReviewer(rin);
 
     String group = gApi.groups().create("foo").get().name;
     gApi.groups().id(group).addMembers(user2.toString(), user3.toString());
@@ -3218,8 +3236,8 @@
     assertQuery("reviewerin:\"Registered Users\"", change2, change1);
     assertQuery("reviewerin:" + group, change2);
 
-    gApi.changes().id(change2.getId().get()).current().review(ReviewInput.approve());
-    gApi.changes().id(change2.getId().get()).current().submit();
+    getChangeApi(change2).current().review(ReviewInput.approve());
+    getChangeApi(change2).current().submit();
 
     assertQuery("reviewerin:" + group, change2);
     assertQuery("project:repo reviewerin:" + group, change2);
@@ -3244,12 +3262,12 @@
     ReviewerInput rin = new ReviewerInput();
     rin.reviewer = userByEmailWithName;
     rin.state = ReviewerState.REVIEWER;
-    gApi.changes().id(change1.getId().get()).addReviewer(rin);
+    getChangeApi(change1).addReviewer(rin);
 
     rin = new ReviewerInput();
     rin.reviewer = userByEmailWithName;
     rin.state = ReviewerState.CC;
-    gApi.changes().id(change2.getId().get()).addReviewer(rin);
+    getChangeApi(change2).addReviewer(rin);
 
     assertQuery("reviewer:\"" + userByEmailWithName + "\"", change1);
     assertQuery("cc:\"" + userByEmailWithName + "\"", change2);
@@ -3276,12 +3294,12 @@
     ReviewerInput rin = new ReviewerInput();
     rin.reviewer = userByEmail;
     rin.state = ReviewerState.REVIEWER;
-    gApi.changes().id(change1.getId().get()).addReviewer(rin);
+    getChangeApi(change1).addReviewer(rin);
 
     rin = new ReviewerInput();
     rin.reviewer = userByEmail;
     rin.state = ReviewerState.CC;
-    gApi.changes().id(change2.getId().get()).addReviewer(rin);
+    getChangeApi(change2).addReviewer(rin);
 
     assertQuery("reviewer:\"someone@example.com\"");
     assertQuery("cc:\"someone@example.com\"");
@@ -3294,9 +3312,9 @@
     Change change1 = insert("repo", newChange(repo));
     Change change2 = insert("repo", newChange(repo));
 
-    gApi.changes().id(change1.getId().get()).current().review(ReviewInput.approve());
+    getChangeApi(change1).current().review(ReviewInput.approve());
     requestContext.setContext(newRequestContext(user1));
-    gApi.changes().id(change2.getId().get()).current().review(ReviewInput.recommend());
+    getChangeApi(change2).current().review(ReviewInput.recommend());
     requestContext.setContext(newRequestContext(user.getAccountId()));
 
     assertQuery("is:submittable", change1);
@@ -3313,7 +3331,7 @@
     assertQuery("label:CodE-RevieW=need,user1");
     assertQuery("label:CodE-RevieW=need,user");
 
-    gApi.changes().id(change1.getId().get()).current().submit();
+    getChangeApi(change1).current().submit();
     assertQuery("is:submittable");
     assertQuery("-is:submittable", change1, change2);
   }
@@ -3354,10 +3372,10 @@
     // Change1 has one resolved comment (unresolvedcount = 0)
     // Change2 has one unresolved comment (unresolvedcount = 1)
     // Change3 has one resolved comment and one unresolved comment (unresolvedcount = 1)
-    addComment(change1.getChangeId(), "comment 1", false);
-    addComment(change2.getChangeId(), "comment 2", true);
-    addComment(change3.getChangeId(), "comment 3", false);
-    addComment(change3.getChangeId(), "comment 4", true);
+    addComment(change1, "comment 1", false);
+    addComment(change2, "comment 2", true);
+    addComment(change3, "comment 3", false);
+    addComment(change3, "comment 4", true);
 
     assertQuery("has:unresolved", change3, change2);
 
@@ -3543,8 +3561,8 @@
     repo = createAndOpenProject("repo");
     // Create two commits and revert second commit (initial commit can't be reverted)
     Change initial = insert("repo", newChange(repo));
-    gApi.changes().id(initial.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(initial.getChangeId()).current().submit();
+    getChangeApi(initial).current().review(ReviewInput.approve());
+    getChangeApi(initial).current().submit();
 
     ChangeInfo changeToRevert =
         gApi.changes().create(new ChangeInput("repo", "master", "commit to revert")).get();
@@ -3561,9 +3579,9 @@
     Change change = insert("repo", newChange(repo));
     // create irrelevant change
     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;
+    getChangeApi(change).current().review(ReviewInput.approve());
+    getChangeApi(change).current().submit();
+    String submissionId = getChangeApi(change).get().submissionId;
 
     assertQueryByIds("submissionid:" + submissionId, change.getId());
   }
@@ -3628,7 +3646,7 @@
       requestContext.setContext(newRequestContext(ownerId));
       Change change = insert("repo", newChange(repo), ownerId);
       id = change.getId();
-      ChangeApi cApi = gApi.changes().id(change.getChangeId());
+      ChangeApi cApi = getChangeApi(change);
       if (wip) {
         cApi.setWorkInProgress();
       }
@@ -3649,15 +3667,15 @@
       in.message = "message";
       for (Account.Id commenterId : draftCommentBy) {
         requestContext.setContext(newRequestContext(commenterId));
-        gApi.changes().id(change.getChangeId()).current().createDraft(in);
+        getChangeApi(change).current().createDraft(in);
       }
       for (Account.Id commenterId : deleteDraftCommentBy) {
         requestContext.setContext(newRequestContext(commenterId));
-        gApi.changes().id(change.getChangeId()).current().createDraft(in).delete();
+        getChangeApi(change).current().createDraft(in).delete();
       }
       if (mergedBy != null) {
         requestContext.setContext(newRequestContext(mergedBy));
-        cApi = gApi.changes().id(change.getChangeId());
+        cApi = getChangeApi(change);
         cApi.current().review(ReviewInput.approve());
         cApi.current().submit();
       }
@@ -3829,7 +3847,7 @@
     Change change2 = insert("repo", newChange(repo));
 
     AttentionSetInput input = new AttentionSetInput(userId.toString(), "some reason");
-    gApi.changes().id(change1.getChangeId()).addToAttentionSet(input);
+    getChangeApi(change1).addToAttentionSet(input);
 
     assertQuery("is:attention", change1);
     assertQuery("-is:attention", change2);
@@ -3838,8 +3856,7 @@
     assertQuery("attention:" + userAccount.preferredEmail(), change1);
     assertQuery("-attention:" + userId.toString(), change2);
 
-    gApi.changes()
-        .id(change1.getChangeId())
+    getChangeApi(change1)
         .attention(userId.toString())
         .remove(new AttentionSetInput("removed again"));
     assertQuery("-is:attention", change1, change2);
@@ -3852,7 +3869,7 @@
     Change change = insert("repo", newChange(repo));
 
     AttentionSetInput input = new AttentionSetInput(userId.toString(), "reason 1");
-    gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
+    getChangeApi(change).addToAttentionSet(input);
     Account.Id user2Id =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
 
@@ -3861,10 +3878,10 @@
     ReviewerInput reviewerInput = new ReviewerInput();
     reviewerInput.reviewer = user2Id.toString();
     reviewerInput.state = ReviewerState.CC;
-    gApi.changes().id(change.getChangeId()).addReviewer(reviewerInput);
+    getChangeApi(change).addReviewer(reviewerInput);
 
     input = new AttentionSetInput(user2Id.toString(), "reason 2");
-    gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
+    getChangeApi(change).addToAttentionSet(input);
 
     List<ChangeInfo> result = newQuery("attention:" + user2Id.toString()).get();
     assertThat(result).hasSize(1);
@@ -3877,7 +3894,7 @@
 
   @GerritConfig(name = "accounts.visibility", value = "NONE")
   @Test
-  public void userDestination() throws Exception {
+  public void namedDestination() throws Exception {
     createProject("repo1");
     Change change1 = insert("repo1", newChange("repo1"));
     createProject("repo2");
@@ -3887,6 +3904,8 @@
         .hasMessageThat()
         .isEqualTo("Unknown named destination: foo");
 
+    String group = "test-group";
+    AccountGroup.UUID groupId = groupOperations.newGroup().name(group).create();
     Account.Id anotherUserId =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     String destination1 = "refs/heads/master\trepo1";
@@ -3930,6 +3949,13 @@
       Ref anotherUserRef = allUsers.getRepository().exactRef(anotherRefsUsers);
       assertThat(userRef).isNotNull();
       assertThat(anotherUserRef).isNotNull();
+
+      String groupRef = RefNames.refsGroups(groupId);
+      allUsers.branch(groupRef).commit().add("destinations/destination1", destination1).create();
+      allUsers.branch(groupRef).commit().add("destinations/destination2", destination2).create();
+      allUsers.branch(groupRef).commit().add("destinations/destination3", destination3).create();
+      allUsers.branch(groupRef).commit().add("destinations/destination4", destination4).create();
+      assertThat(allUsers.getRepository().exactRef(groupRef)).isNotNull();
     }
 
     assertQuery("destination:destination1", change1);
@@ -3955,15 +3981,35 @@
     assertThatQueryException("destination:destination3,user=" + userId)
         .hasMessageThat()
         .isEqualTo(String.format("Account '%s' not found", userId));
+
+    // Group destinations
+    requestContext.setContext(newRequestContext(userId));
+    assertThatQueryException("destination:non-existent-dest,group=" + group)
+        .hasMessageThat()
+        .isEqualTo("Unknown named destination: non-existent-dest");
+    assertThatQueryException("destination:destination1,group=non-existent-group")
+        .hasMessageThat()
+        .isEqualTo("Group non-existent-group not found");
+    assertThatQueryException("destination:destination1,group=" + group + ",user=" + userId)
+        .hasMessageThat()
+        .isEqualTo("User and group arguments are mutually exclusive");
+
+    assertQuery("destination:destination1,group=" + group, change1);
+    assertQuery("destination:name=destination1,group=" + group, change1);
+    assertQuery("destination:group=" + group + ",destination2", change2);
+    assertQuery("destination:group=" + group + ",name=destination3", change2, change1);
+    assertQuery("destination:destination4,group=" + group);
   }
 
   @GerritConfig(name = "accounts.visibility", value = "NONE")
   @Test
-  public void userQuery() throws Exception {
+  public void namedQuery() throws Exception {
     repo = createAndOpenProject("repo");
     Change change1 = insert("repo", newChange(repo));
     Change change2 = insert("repo", newChangeForBranch(repo, "stable"));
 
+    String group = "test-group";
+    AccountGroup.UUID groupId = groupOperations.newGroup().name(group).create();
     Account.Id anotherUserId = createAccount("anotheruser");
     String queryListText =
         "query1\tproject:repo\n"
@@ -3980,14 +4026,20 @@
             new TestRepository<>(repoManager.openRepository(allUsersName));
         MetaDataUpdate md = metaDataUpdateFactory.create(allUsersName);
         MetaDataUpdate anotherMd = metaDataUpdateFactory.create(allUsersName)) {
-      VersionedAccountQueries queries = VersionedAccountQueries.forUser(userId);
+      VersionedAccountQueries queries =
+          VersionedAccountQueries.forBranch(
+              BranchNameKey.create(allUsersName, RefNames.refsUsers(userId)));
       queries.load(md);
       queries.setQueryList(queryListText);
       queries.commit(md);
-      VersionedAccountQueries anotherQueries = VersionedAccountQueries.forUser(anotherUserId);
+      VersionedAccountQueries anotherQueries =
+          VersionedAccountQueries.forBranch(
+              BranchNameKey.create(allUsersName, RefNames.refsUsers(anotherUserId)));
       anotherQueries.load(anotherMd);
       anotherQueries.setQueryList(anotherQueryListText);
       anotherQueries.commit(anotherMd);
+
+      allUsers.branch(RefNames.refsGroups(groupId)).commit().add("queries", queryListText).create();
     }
 
     assertThat(gApi.accounts().self().get()._accountId).isEqualTo(userId.get());
@@ -4010,14 +4062,31 @@
     assertQuery("query:query2", change2, change1);
     assertQuery("query:name=query5,user=" + anotherUserId, change2, change1);
     assertQuery("query:user=" + anotherUserId + ",name=query6");
-    gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(change1.getChangeId()).current().submit();
+    getChangeApi(change1).current().review(ReviewInput.approve());
+    getChangeApi(change1).current().submit();
     assertQuery("query:query2", change2);
     assertQuery("query:query3", change2);
     assertQuery("query:query4");
     assertQuery("query:query6,user=" + anotherUserId, change1);
     assertQuery("query:user=" + anotherUserId + ",query7", change2);
     assertQuery("query:query8,user=" + anotherUserId);
+
+    // Group queries
+    assertThatQueryException("query:non-existent,group=" + group)
+        .hasMessageThat()
+        .isEqualTo("Unknown named query: non-existent");
+    assertThatQueryException("query:query1,group=non-existent-group")
+        .hasMessageThat()
+        .isEqualTo("Group non-existent-group not found");
+    assertThatQueryException("query:query1,group=" + group + ",user=" + userId)
+        .hasMessageThat()
+        .isEqualTo("User and group arguments are mutually exclusive");
+
+    assertQuery("query:name=query1,group=" + group, change1, change2);
+    assertQuery("query:query1,group=" + group, change1, change2);
+    assertQuery("query:group=" + group + ",name=query2", change2);
+    assertQuery("query:group=" + group + ",query4");
+    assertQuery("query:name=query4,group=" + group);
   }
 
   @Test
@@ -4028,7 +4097,7 @@
     String query = "change:" + change.getId();
     assertQuery(query, change);
 
-    gApi.changes().id(change.getChangeId()).delete();
+    getChangeApi(change).delete();
     assertQuery(query);
   }
 
@@ -4071,8 +4140,8 @@
     repo = createAndOpenProject("repo");
     // Create two commits and revert second commit (initial commit can't be reverted)
     Change initial = insert("repo", newChange(repo));
-    gApi.changes().id(initial.getChangeId()).current().review(ReviewInput.approve());
-    gApi.changes().id(initial.getChangeId()).current().submit();
+    getChangeApi(initial).current().review(ReviewInput.approve());
+    getChangeApi(initial).current().submit();
 
     ChangeInfo changeToRevert =
         gApi.changes().create(new ChangeInput("repo", "master", "commit to revert")).get();
@@ -4116,7 +4185,7 @@
 
     repo = createAndOpenProject("repo");
     Change change = insert("repo", newChange(repo));
-    gApi.changes().id(change.getId().get()).addReviewer(user2.toString());
+    getChangeApi(change).addReviewer(user2.toString());
 
     RequestContext adminContext = requestContext.setContext(newRequestContext(user2));
     assertQuery("reviewer:self", change);
@@ -4163,6 +4232,38 @@
         .contains("'is:mergeable' operator is not supported on this gerrit host");
   }
 
+  @Test
+  public void customKeyedValue() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.CUSTOM_KEYED_VALUES_SPEC)).isTrue();
+
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChange(repo));
+    CustomKeyedValuesInput in = new CustomKeyedValuesInput();
+    in.add = ImmutableMap.of("workspace", "my-ws");
+    getChangeApi(change1).setCustomKeyedValues(in);
+
+    Change change2 = insert("repo", newChange(repo));
+
+    in = new CustomKeyedValuesInput();
+    in.add = ImmutableMap.of("workspace", "123");
+    getChangeApi(change2).setCustomKeyedValues(in);
+
+    // Insert a change without a KV pair
+    insert("repo", newChange(repo));
+
+    assertThat(customKeyedValues("workspace="))
+        .containsExactly(change1.getChangeId(), change2.getChangeId());
+    assertThat(customKeyedValues("workspace=my")).containsExactly(change1.getChangeId());
+    assertThat(customKeyedValues("workspace=123")).containsExactly(change2.getChangeId());
+    assertThat(customKeyedValues("workspace=foo-bar")).isEmpty();
+  }
+
+  protected List<Integer> customKeyedValues(String query) {
+    return queryProvider.get().byCustomKeyedValue(query).stream()
+        .map(cd -> cd.getId().get())
+        .collect(toList());
+  }
+
   protected ChangeInserter newChangeForCommit(TestRepository<Repository> repo, RevCommit commit)
       throws Exception {
     return newChange(repo, commit, null, null, null, null, false, false);
@@ -4471,16 +4572,16 @@
 
   // Get the last  updated time from ChangeApi
   protected long lastUpdatedMsApi(Change c) throws Exception {
-    return gApi.changes().id(c.getChangeId()).get().updated.getTime();
+    return getChangeApi(c).get().updated.getTime();
   }
 
   protected void approve(Change change) throws Exception {
-    gApi.changes().id(change.getChangeId()).current().review(ReviewInput.approve());
+    getChangeApi(change).current().review(ReviewInput.approve());
   }
 
   protected void submit(Change change) throws Exception {
     approve(change);
-    gApi.changes().id(change.getChangeId()).current().submit();
+    getChangeApi(change).current().submit();
   }
 
   /**
@@ -4525,14 +4626,14 @@
     return String.format(queryPattern, emptyGroupName, searchTerm);
   }
 
-  private void addComment(int changeId, String message, Boolean unresolved) throws Exception {
+  private void addComment(Change change, String message, Boolean unresolved) throws Exception {
     ReviewInput input = new ReviewInput();
     ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
     comment.line = 1;
     comment.message = message;
     comment.unresolved = unresolved;
     input.comments = ImmutableMap.of(Patch.COMMIT_MSG, ImmutableList.of(comment));
-    gApi.changes().id(changeId).current().review(input);
+    getChangeApi(change).current().review(input);
   }
 
   private Account.Id createAccount(String username, String fullName, String email, boolean active)
@@ -4595,4 +4696,8 @@
   PaginationType getCurrentPaginationType() {
     return config.getEnum("index", null, "paginationType", PaginationType.OFFSET);
   }
+
+  private ChangeApi getChangeApi(Change change) throws RestApiException {
+    return gApi.changes().id(change.getProject().get(), change.getChangeId());
+  }
 }
diff --git a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
index 7641544..5d54baf 100644
--- a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
@@ -106,10 +106,10 @@
     Change invisibleChange3 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW), user2);
     Change invisibleChange4 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW), user2);
     Change invisibleChange5 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW), user2);
-    gApi.changes().id(invisibleChange2.getChangeId()).setPrivate(true, null);
-    gApi.changes().id(invisibleChange3.getChangeId()).setPrivate(true, null);
-    gApi.changes().id(invisibleChange4.getChangeId()).setPrivate(true, null);
-    gApi.changes().id(invisibleChange5.getChangeId()).setPrivate(true, null);
+    gApi.changes().id(invisibleChange2.getKey().get()).setPrivate(true, null);
+    gApi.changes().id(invisibleChange3.getKey().get()).setPrivate(true, null);
+    gApi.changes().id(invisibleChange4.getKey().get()).setPrivate(true, null);
+    gApi.changes().id(invisibleChange5.getKey().get()).setPrivate(true, null);
 
     AbstractFakeIndex<?, ?, ?> idx =
         (AbstractFakeIndex<?, ?, ?>) changeIndexCollection.getSearchIndex();
@@ -213,13 +213,12 @@
     // 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.
-    queryProvider.get().query(queryBuilder.parse("status:new"));
+    executeQuery("status:new");
     assertThat(idx.getQueryCount()).isEqualTo(LIMIT);
   }
 
   @Test
   @UseClockStep
-  @SuppressWarnings("unchecked")
   public void internalQueriesDoNotPaginateWithNonePaginationType() throws Exception {
     assumeTrue(PaginationType.NONE == getCurrentPaginationType());
 
@@ -229,8 +228,9 @@
     assertThatSearchQueryWasNotPaginated(idx.getQueryCount());
   }
 
+  @SuppressWarnings("unused")
   private void executeQuery(String query) throws QueryParseException {
-    queryProvider.get().query(queryBuilder.parse(query));
+    List<ChangeData> unused = queryProvider.get().query(queryBuilder.parse(query));
   }
 
   private void assertThatSearchQueryWasNotPaginated(int queryCount) {
diff --git a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
index d9a0767..e7600d9 100644
--- a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
@@ -107,8 +107,8 @@
 
     Change invisibleChange1 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW), user2);
     Change invisibleChange2 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW), user2);
-    gApi.changes().id(invisibleChange1.getChangeId()).setPrivate(true, null);
-    gApi.changes().id(invisibleChange2.getChangeId()).setPrivate(true, null);
+    gApi.changes().id(invisibleChange1.getKey().get()).setPrivate(true, null);
+    gApi.changes().id(invisibleChange2.getKey().get()).setPrivate(true, null);
 
     // pagination should back-fill when the results skipped because of the visibility
     assertQuery(newQuery("status:new").withLimit(1), expected);
diff --git a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
index b119104..47d485d 100644
--- a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
+++ b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
@@ -23,7 +23,8 @@
 
 import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Project;
@@ -69,13 +70,18 @@
 import java.util.Iterator;
 import java.util.List;
 import java.util.Locale;
-import java.util.Set;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Ignore;
 import org.junit.Rule;
 import org.junit.Test;
 
+/**
+ * Tests queries against the project index.
+ *
+ * <p>Note, returned projects are sorted by name. Projects that start with a capital letter are
+ * returned first.
+ */
 @Ignore
 public abstract class AbstractQueryProjectsTest extends GerritServerTests {
   @Rule public final GerritTestName testName = new GerritTestName();
@@ -112,6 +118,8 @@
   protected Injector injector;
   protected AccountInfo currentUserInfo;
   protected CurrentUser user;
+  protected ProjectInfo allProjectsInfo;
+  protected ProjectInfo allUsersInfo;
 
   protected abstract Injector createInjector();
 
@@ -141,6 +149,13 @@
     user = userFactory.create(userId);
     requestContext.setContext(newRequestContext(userId));
     currentUserInfo = gApi.accounts().id(userId.get()).get();
+
+    // All-Projects and All-Users are not indexed, index them now.
+    gApi.projects().name(allProjects.get()).index(/* indexChildren= */ false);
+    gApi.projects().name(allUsers.get()).index(/* indexChildren= */ false);
+
+    allProjectsInfo = gApi.projects().name(allProjects.get()).get();
+    allUsersInfo = gApi.projects().name(allUsers.get()).get();
   }
 
   protected void initAfterLifecycleStart() throws Exception {}
@@ -163,6 +178,13 @@
   }
 
   @Test
+  public void byEmptyQuery() throws Exception {
+    ProjectInfo project1 = createProject(name("project1"));
+    ProjectInfo project2 = createProject(name("project2"));
+    assertQuery("", allProjectsInfo, allUsersInfo, project1, project2);
+  }
+
+  @Test
   public void byName() throws Exception {
     assertQuery("name:project");
     assertQuery("name:non-existing");
@@ -170,6 +192,8 @@
     ProjectInfo project = createProject(name("project"));
 
     assertQuery("name:" + project.name, project);
+    assertQuery("name:" + allProjects.get(), allProjectsInfo);
+    assertQuery("name:" + allUsers.get(), allUsersInfo);
 
     // only exact match
     ProjectInfo projectWithHyphen = createProject(name("project-with-hyphen"));
@@ -178,6 +202,49 @@
   }
 
   @Test
+  public void byPrefix() throws Exception {
+    assume().that(getSchemaVersion() >= 8).isTrue();
+
+    assertQuery("prefix:project");
+    assertQuery("prefix:non-existing");
+    assertQuery("prefix:All", allProjectsInfo, allUsersInfo);
+    assertQuery("prefix:All-", allProjectsInfo, allUsersInfo);
+
+    ProjectInfo project1 = createProject(name("project-1"));
+    ProjectInfo project2 = createProject(name("project-2"));
+    ProjectInfo testProject = createProject(name("test-project"));
+
+    assertQuery("prefix:project", project1, project2);
+    assertQuery("prefix:test", testProject);
+    assertQuery("prefix:TEST");
+  }
+
+  @Test
+  public void byPrefixWithOtherCase() throws Exception {
+    assume().that(getSchemaVersion() >= 8).isTrue();
+
+    assertQuery("prefix:all");
+
+    createProject(name("test-project"));
+    assertQuery("prefix:TEST");
+  }
+
+  @Test
+  public void bySubstring() throws Exception {
+    assertQuery("substring:non-existing");
+
+    ProjectInfo project1 = createProject(name("project-1"));
+    ProjectInfo project2 = createProject(name("project-2"));
+    ProjectInfo testProject = createProject(name("test-project"));
+    ProjectInfo myTests = createProject(name("MY-TESTS"));
+
+    assertQuery("substring:project", allProjectsInfo, project1, project2, testProject);
+    assertQuery("substring:PROJECT", allProjectsInfo, project1, project2, testProject);
+    assertQuery("substring:test", myTests, testProject);
+    assertQuery("substring:TEST", myTests, testProject);
+  }
+
+  @Test
   public void byParent() throws Exception {
     assertQuery("parent:project");
     ProjectInfo parent = createProject(name("parent"));
@@ -188,12 +255,32 @@
 
   @Test
   public void byParentOfAllProjects() throws Exception {
-    Set<String> excludedProjects = ImmutableSet.of(allProjects.get(), allUsers.get());
-    ProjectInfo[] projects =
-        gApi.projects().list().get().stream()
-            .filter(p -> !excludedProjects.contains(p.name))
-            .toArray(s -> new ProjectInfo[s]);
-    assertQuery("parent:" + allProjects.get(), projects);
+    assume().that(getSchemaVersion() < 7).isTrue();
+
+    ProjectInfo parent1 = createProject(name("parent1"));
+    createProject(name("child"), parent1.name);
+
+    ProjectInfo parent2 = createProject(name("parent2"));
+    createProject(name("child2"), parent2.name);
+
+    // All-Users should be returned as well, since it's a direct child project under
+    // All-Projects, but it's missing in the result since the parent1 field in the index is not set
+    // for projects that don't have 'access.inheritsFrom' set in project.config (which is the case
+    // for the All-Users project).
+    assertQuery("parent:" + allProjects.get(), parent1, parent2);
+  }
+
+  @Test
+  public void byParentOfAllProjects2() throws Exception {
+    assume().that(getSchemaVersion() >= 7).isTrue();
+
+    ProjectInfo parent1 = createProject(name("parent1"));
+    createProject(name("child"), parent1.name);
+
+    ProjectInfo parent2 = createProject(name("parent2"));
+    createProject(name("child2"), parent2.name);
+
+    assertQuery("parent:" + allProjects.get(), allUsersInfo, parent1, parent2);
   }
 
   @Test
@@ -231,7 +318,7 @@
 
     ProjectInfo project1 = createProjectWithState(name("project1"), ProjectState.ACTIVE);
     ProjectInfo project2 = createProjectWithState(name("project2"), ProjectState.READ_ONLY);
-    assertQuery("state:active", project1);
+    assertQuery("state:active", allProjectsInfo, allUsersInfo, project1);
     assertQuery("state:read-only", project2);
   }
 
@@ -274,8 +361,10 @@
     String query =
         "name:" + project1.name + " OR name:" + project2.name + " OR name:" + project3.name;
     List<ProjectInfo> result = assertQuery(query, project1, project2, project3);
+    assertThat(Iterables.getLast(result)._moreProjects).isNull();
 
-    assertQuery(newQuery(query).withLimit(2), result.subList(0, 2));
+    result = assertQuery(newQuery(query).withLimit(2), result.subList(0, 2));
+    assertThat(Iterables.getLast(result)._moreProjects).isTrue();
   }
 
   @Test
@@ -343,6 +432,7 @@
     return gApi.projects().create(in).get();
   }
 
+  @CanIgnoreReturnValue
   protected ProjectInfo createProject(String name, String parent) throws Exception {
     ProjectInput in = new ProjectInput();
     in.name = name;
diff --git a/javatests/com/google/gerrit/server/query/project/BUILD b/javatests/com/google/gerrit/server/query/project/BUILD
index 53f9d9d..2ae73b3 100644
--- a/javatests/com/google/gerrit/server/query/project/BUILD
+++ b/javatests/com/google/gerrit/server/query/project/BUILD
@@ -21,6 +21,7 @@
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
         "//lib:jgit",
+        "//lib/errorprone:annotations",
         "//lib/guice",
         "//lib/truth",
     ],
diff --git a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
index 2685a8b..eb1d275 100644
--- a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
@@ -17,8 +17,11 @@
 import static com.google.common.collect.MoreCollectors.onlyElement;
 import static com.google.common.truth.Truth.assertThat;
 
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
@@ -28,6 +31,7 @@
 import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
 import org.junit.Test;
@@ -65,6 +69,97 @@
   }
 
   @Test
+  public void commentsLinkedToCorrectAccounts() {
+
+    List<CommentInfo> comments =
+        createComments("c0", "10", "c1", "11", "c2", "11", "c3", "11", "c4", "11", "c5", "21");
+    int accountId1 = 12345;
+    int accountId2 = 12346;
+    int accountId3 = 12347;
+    linkAuthor(comments.get(0), accountId1);
+    // comments 1-4 have same timestamp
+    linkAuthor(comments.get(1), accountId2);
+    linkAuthor(comments.get(2), accountId1);
+    linkAuthor(comments.get(3), accountId1);
+    linkAuthor(comments.get(4), accountId2);
+
+    linkAuthor(comments.get(5), accountId1);
+
+    // Change massages have exactly same timestamps
+    List<ChangeMessage> changeMessages =
+        ImmutableList.of(
+            createChangeMessage("cm0", "10", Account.id(accountId1)),
+            createChangeMessage("cm1", "11", Account.id(accountId1)),
+            createChangeMessage("cm2", "11", Account.id(accountId2)),
+            // unrelated message by other account in-between
+            createChangeMessage("cm2.2", "15", Account.id(accountId3)),
+            createChangeMessage("cm3", "21", Account.id(accountId1)));
+
+    CommentsUtil.linkCommentsToChangeMessages(comments, changeMessages, true);
+
+    assertThat(getComment(comments, "c0").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "cm0").getKey().uuid());
+
+    // belongs to account2 -assigned to account2
+    assertThat(getComment(comments, "c1").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "cm2").getKey().uuid());
+
+    // belongs to account1 -assigned to account1
+    assertThat(getComment(comments, "c2").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "cm1").getKey().uuid());
+    assertThat(getComment(comments, "c3").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "cm1").getKey().uuid());
+
+    // belongs to account2 - assigned to account2
+    assertThat(getComment(comments, "c4").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "cm2").getKey().uuid());
+
+    // different timestamp - assigned to a different message
+    assertThat(getComment(comments, "c5").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "cm3").getKey().uuid());
+
+    // Make sure no comment is linked to the auto-gen message
+    Set<String> changeMessageIds =
+        comments.stream().map(c -> c.changeMessageId).collect(Collectors.toSet());
+    assertThat(changeMessageIds)
+        .doesNotContain(getChangeMessage(changeMessages, "cm2.2").getKey().uuid());
+  }
+
+  @Test
+  public void commentsLinkedToCorrectAccountsIfUserNotMatched() {
+
+    String tsCm0 = "10";
+    String tsCm1 = "11";
+    String tsCm2 = "12";
+    List<CommentInfo> comments =
+        createComments(
+            "commentMessage0", tsCm0, "commentMessage1", tsCm1, "commentMessage2", tsCm2);
+    int accountId1 = 1;
+    int accountId2 = 2;
+    int accountId2Imported = 0;
+    int accountId3 = 3;
+    linkAuthor(comments.get(0), accountId1);
+    linkAuthor(comments.get(1), accountId2Imported);
+    linkAuthor(comments.get(2), accountId3);
+
+    List<ChangeMessage> changeMessages =
+        ImmutableList.of(
+            createChangeMessage("changeMessage0", tsCm0, Account.id(accountId1)),
+            createChangeMessage("changeMessage1", tsCm1, Account.id(accountId2)),
+            createChangeMessage("changeMessage2", tsCm2, Account.id(accountId3)));
+
+    CommentsUtil.linkCommentsToChangeMessages(comments, changeMessages, false);
+
+    assertThat(getComment(comments, "commentMessage0").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "changeMessage0").getKey().uuid());
+
+    assertThat(getComment(comments, "commentMessage1").changeMessageId).isNull();
+
+    assertThat(getComment(comments, "commentMessage2").changeMessageId)
+        .isEqualTo(getChangeMessage(changeMessages, "changeMessage2").getKey().uuid());
+  }
+
+  @Test
   public void commentsLinkedToChangeMessagesAllowLinkingToAutoGenTaggedMessages() {
     /* Human comments are allowed to be linked to autogenerated messages */
     List<CommentInfo> comments = createComments("c1", "00", "c2", "10", "c3", "25");
@@ -100,6 +195,10 @@
     return comments;
   }
 
+  private void linkAuthor(CommentInfo commentInfo, int accountId) {
+    commentInfo.author = new AccountInfo(accountId);
+  }
+
   /**
    * Create a list of change messages from the specified args args should be passed as consecutive
    * pairs of messages and timestamps example: (m1, t1, m2, t2, ...). the tag parameter for the
@@ -108,14 +207,24 @@
   private static List<ChangeMessage> createChangeMessages(String... args) {
     List<ChangeMessage> changeMessages = new ArrayList<>();
     for (int i = 0; i < args.length; i += 2) {
-      String key = args[i] + "Key";
       String message = args[i];
       String ts = args[i + 1];
-      changeMessages.add(newChangeMessage(key, message, ts, null));
+      changeMessages.add(createChangeMessage(message, ts, Optional.empty()));
     }
     return changeMessages;
   }
 
+  /** Creates a ChangeMessages with the specified author. */
+  private static ChangeMessage createChangeMessage(String message, String ts, Account.Id author) {
+    return createChangeMessage(message, ts, Optional.of(author));
+  }
+
+  private static ChangeMessage createChangeMessage(
+      String message, String ts, Optional<Account.Id> author) {
+    String key = message + "Key";
+    return newChangeMessage(key, author, message, ts, null);
+  }
+
   /** Create a new CommentInfo with a given message and timestamp */
   private static CommentInfo newCommentInfo(String message, String ts) {
     CommentInfo c = new CommentInfo();
@@ -126,12 +235,18 @@
 
   /** Create a new change message with an id, message, timestamp and tag */
   private static ChangeMessage newChangeMessage(String id, String message, String ts, String tag) {
+    return newChangeMessage(id, Optional.empty(), message, ts, tag);
+  }
+
+  private static ChangeMessage newChangeMessage(
+      String id, Optional<Account.Id> accountId, String message, String ts, String tag) {
     ChangeMessage.Key key = ChangeMessage.key(Change.id(1), id);
     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);
+    ChangeMessage cm =
+        ChangeMessage.create(key, accountId.orElse(null), timestamp, null, message, null, tag);
     return cm;
   }
 
diff --git a/javatests/com/google/gerrit/server/rules/BUILD b/javatests/com/google/gerrit/server/rules/BUILD
index 5c57ede..b1ab27e 100644
--- a/javatests/com/google/gerrit/server/rules/BUILD
+++ b/javatests/com/google/gerrit/server/rules/BUILD
@@ -1,22 +1,12 @@
 load("//tools/bzl:junit.bzl", "junit_tests")
 
 junit_tests(
-    name = "prolog_tests",
+    name = "rules_tests",
     srcs = glob(["*.java"]),
-    resource_strip_prefix = "prologtests",
-    resources = ["//prologtests:gerrit_common_test"],
-    runtime_deps = ["//prolog:gerrit-prolog-common"],
     deps = [
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/project/testing:project-test-util",
-        "//java/com/google/gerrit/server/util/time",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
         "//lib:guava",
-        "//lib:jgit",
-        "//lib/guice",
-        "//lib/mockito",
-        "//lib/prolog:runtime",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/rules/prolog/BUILD b/javatests/com/google/gerrit/server/rules/prolog/BUILD
new file mode 100644
index 0000000..ce02a06
--- /dev/null
+++ b/javatests/com/google/gerrit/server/rules/prolog/BUILD
@@ -0,0 +1,23 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "prolog_tests",
+    srcs = glob(["*.java"]),
+    resource_strip_prefix = "prologtests",
+    resources = ["//prologtests:gerrit_common_test"],
+    runtime_deps = ["//prolog:gerrit-prolog-common"],
+    deps = [
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/project/testing:project-test-util",
+        "//java/com/google/gerrit/server/rules/prolog",
+        "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
+        "//lib:jgit",
+        "//lib/guice",
+        "//lib/mockito",
+        "//lib/prolog:runtime",
+        "//lib/truth",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java b/javatests/com/google/gerrit/server/rules/prolog/GerritCommonTest.java
similarity index 98%
rename from javatests/com/google/gerrit/server/rules/GerritCommonTest.java
rename to javatests/com/google/gerrit/server/rules/prolog/GerritCommonTest.java
index 871c871..4f9863e 100644
--- a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
+++ b/javatests/com/google/gerrit/server/rules/prolog/GerritCommonTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
diff --git a/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java b/javatests/com/google/gerrit/server/rules/prolog/PrologRuleEvaluatorTest.java
similarity index 96%
rename from javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
rename to javatests/com/google/gerrit/server/rules/prolog/PrologRuleEvaluatorTest.java
index a5357e1..703ed7b 100644
--- a/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
+++ b/javatests/com/google/gerrit/server/rules/prolog/PrologRuleEvaluatorTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import static com.google.common.truth.Truth.assertThat;
 
diff --git a/javatests/com/google/gerrit/server/rules/PrologTestCase.java b/javatests/com/google/gerrit/server/rules/prolog/PrologTestCase.java
similarity index 98%
rename from javatests/com/google/gerrit/server/rules/PrologTestCase.java
rename to javatests/com/google/gerrit/server/rules/prolog/PrologTestCase.java
index c2b6dbb..52a8314 100644
--- a/javatests/com/google/gerrit/server/rules/PrologTestCase.java
+++ b/javatests/com/google/gerrit/server/rules/prolog/PrologTestCase.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
diff --git a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
index e6a6497..6c79c43 100644
--- a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
+++ b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
@@ -30,11 +30,11 @@
 import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.GroupUuid;
 import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.inject.Inject;
 import org.eclipse.jgit.lib.Config;
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
index 767ac28..5d5ef4e 100644
--- a/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
@@ -24,13 +24,13 @@
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.Sequence;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.IntBlob;
 import com.google.gerrit.server.notedb.RepoSequence;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.TestUpdateUI;
 import java.io.IOException;
@@ -150,7 +150,7 @@
               repoManager,
               GitReferenceUpdated.DISABLED,
               allUsersName,
-              Sequences.NAME_GROUPS,
+              Sequence.NAME_GROUPS,
               () -> 1,
               1)
           .next();
diff --git a/javatests/com/google/gerrit/server/submit/BUILD b/javatests/com/google/gerrit/server/submit/BUILD
index 01acb72..fcccf92 100644
--- a/javatests/com/google/gerrit/server/submit/BUILD
+++ b/javatests/com/google/gerrit/server/submit/BUILD
@@ -12,6 +12,7 @@
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//java/com/google/gerrit/testing:test-ref-update-context",
+        "//lib:guava",
         "//lib:jgit",
         "//lib/mockito",
         "//lib/truth",
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index 0a6e315..99ac425 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
+import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.change.AbandonOp;
@@ -50,7 +51,6 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.update.context.RefUpdateContext;
diff --git a/javatests/com/google/gerrit/server/update/context/BUILD b/javatests/com/google/gerrit/server/update/context/BUILD
index e580595..65e544b 100644
--- a/javatests/com/google/gerrit/server/update/context/BUILD
+++ b/javatests/com/google/gerrit/server/update/context/BUILD
@@ -8,6 +8,7 @@
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//java/com/google/gerrit/testing:test-ref-update-context",
+        "//lib:guava",
         "//lib/truth",
         "//lib/truth:truth-java8-extension",
     ],
diff --git a/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java b/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java
index 178d67d..0646669 100644
--- a/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java
+++ b/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java
@@ -15,12 +15,16 @@
 package com.google.gerrit.server.update.context;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.DIRECT_PUSH;
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.GROUPS_UPDATE;
 import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.INIT_REPO;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.OTHER;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
+import java.util.Optional;
 import org.junit.After;
 import org.junit.Test;
 
@@ -90,4 +94,44 @@
       }
     }
   }
+
+  @Test
+  public void openDirectPushContextByType_exceptionThrown() {
+    assertThrows(Exception.class, () -> RefUpdateContext.open(DIRECT_PUSH));
+  }
+
+  @Test
+  public void openDirectPushContextWithJustification_openedAndClosedCorrectly() {
+    try (RefUpdateContext ctx = RefUpdateContext.openDirectPush(Optional.of("Open in test"))) {
+      ImmutableList<RefUpdateContext> openedContexts = RefUpdateContext.getOpenedContexts();
+      assertThat(openedContexts).hasSize(1);
+      assertThat(openedContexts.get(0).getUpdateType()).isEqualTo(DIRECT_PUSH);
+      assertThat(openedContexts.get(0).getJustification()).hasValue("Open in test");
+      assertThat(RefUpdateContext.hasOpen(DIRECT_PUSH)).isTrue();
+      assertThat(RefUpdateContext.hasOpen(INIT_REPO)).isFalse();
+    }
+
+    assertThat(RefUpdateContext.getOpenedContexts()).isEmpty();
+    assertThat(RefUpdateContext.hasOpen(DIRECT_PUSH)).isFalse();
+  }
+
+  @Test
+  public void openDirectPushContextWithoutJustification_openedAndClosedCorrectly() {
+    try (RefUpdateContext ctx = RefUpdateContext.openDirectPush(Optional.empty())) {
+      ImmutableList<RefUpdateContext> openedContexts = RefUpdateContext.getOpenedContexts();
+      assertThat(openedContexts).hasSize(1);
+      assertThat(openedContexts.get(0).getUpdateType()).isEqualTo(DIRECT_PUSH);
+      assertThat(openedContexts.get(0).getJustification()).isEmpty();
+      assertThat(RefUpdateContext.hasOpen(DIRECT_PUSH)).isTrue();
+      assertThat(RefUpdateContext.hasOpen(INIT_REPO)).isFalse();
+    }
+
+    assertThat(RefUpdateContext.getOpenedContexts()).isEmpty();
+    assertThat(RefUpdateContext.hasOpen(DIRECT_PUSH)).isFalse();
+  }
+
+  @Test
+  public void openOtherContextByType_exceptionThrown() {
+    assertThrows(Exception.class, () -> RefUpdateContext.open(OTHER));
+  }
 }
diff --git a/javatests/com/google/gerrit/util/http/BUILD b/javatests/com/google/gerrit/util/http/BUILD
index 4711faa..36958a3 100644
--- a/javatests/com/google/gerrit/util/http/BUILD
+++ b/javatests/com/google/gerrit/util/http/BUILD
@@ -8,6 +8,7 @@
         "//javatests/com/google/gerrit/util/http/testutil",
         "//lib:junit",
         "//lib:servlet-api-without-neverlink",
+        "//lib/mockito",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/util/http/RequestUtilTest.java b/javatests/com/google/gerrit/util/http/RequestUtilTest.java
index bef9d4b1..c6d1ff7 100644
--- a/javatests/com/google/gerrit/util/http/RequestUtilTest.java
+++ b/javatests/com/google/gerrit/util/http/RequestUtilTest.java
@@ -17,8 +17,12 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.util.http.RequestUtil.getEncodedPathInfo;
 import static com.google.gerrit.util.http.RequestUtil.getRestPathWithoutIds;
+import static org.mockito.Mockito.mock;
 
 import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
+import java.util.function.Supplier;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpSession;
 import org.junit.Test;
 
 public class RequestUtilTest {
@@ -64,10 +68,42 @@
         .isEqualTo("/accounts/test");
   }
 
+  @Test
+  public void getSession_create() {
+    HttpServletRequest req = fakeRequest("/", "/", "/foo", () -> mock(HttpSession.class), null);
+    assertThat(req.getSession(false)).isNull();
+    assertThat(req.getSession(true)).isNotNull();
+    assertThat(req.getSession()).isNotNull();
+  }
+
+  @Test
+  public void getSession_getExisting() {
+    HttpServletRequest req = fakeRequest("/", "/", "/foo", null, mock(HttpSession.class));
+    assertThat(req.getSession(false)).isNotNull();
+  }
+
+  @Test
+  public void getRequestURI_shouldNotInclueQueryString() {
+    FakeHttpServletRequest req = fakeRequest("/", "/", "/foo");
+    req.setQueryString("query=foo");
+    assertThat(req.getRequestURI()).endsWith("/foo");
+    assertThat(req.getRequestURI()).doesNotContain("?query=foo");
+  }
+
   private FakeHttpServletRequest fakeRequest(
       String contextPath, String servletPath, String pathInfo) {
+    return fakeRequest(contextPath, servletPath, pathInfo, null, null);
+  }
+
+  private FakeHttpServletRequest fakeRequest(
+      String contextPath,
+      String servletPath,
+      String pathInfo,
+      Supplier<HttpSession> newSessionSupplier,
+      HttpSession currentSession) {
     FakeHttpServletRequest req =
-        new FakeHttpServletRequest("gerrit.example.com", 80, contextPath, servletPath);
+        new FakeHttpServletRequest(
+            "gerrit.example.com", 80, contextPath, servletPath, newSessionSupplier, currentSession);
     return req.setPathInfo(pathInfo);
   }
 }
diff --git a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
index 0347177..78b362d 100644
--- a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
+++ b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
@@ -38,6 +38,7 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.function.Supplier;
 import javax.servlet.AsyncContext;
 import javax.servlet.DispatcherType;
 import javax.servlet.RequestDispatcher;
@@ -69,12 +70,24 @@
   private String servletPath;
   private String path;
   private String method;
+  private final Supplier<HttpSession> sessionSupplier;
+  private HttpSession session;
 
   public FakeHttpServletRequest() {
     this("gerrit.example.com", 80, "", SERVLET_PATH);
   }
 
   public FakeHttpServletRequest(String hostName, int port, String contextPath, String servletPath) {
+    this(hostName, port, contextPath, servletPath, null, null);
+  }
+
+  public FakeHttpServletRequest(
+      String hostName,
+      int port,
+      String contextPath,
+      String servletPath,
+      @Nullable Supplier<HttpSession> sessionSupplier,
+      @Nullable HttpSession currentSession) {
     this.hostName = requireNonNull(hostName, "hostName");
     checkArgument(port > 0);
     this.port = port;
@@ -84,6 +97,8 @@
     parameters = LinkedListMultimap.create();
     headers = LinkedListMultimap.create();
     method = "GET";
+    this.sessionSupplier = sessionSupplier;
+    this.session = currentSession;
   }
 
   @Override
@@ -340,11 +355,7 @@
 
   @Override
   public String getRequestURI() {
-    String uri = contextPath + servletPath + path;
-    if (!Strings.isNullOrEmpty(queryString)) {
-      uri += '?' + queryString;
-    }
-    return uri;
+    return contextPath + servletPath + path;
   }
 
   @Override
@@ -364,12 +375,22 @@
 
   @Override
   public HttpSession getSession() {
-    throw new UnsupportedOperationException();
+    return getSession(true);
   }
 
   @Override
+  @Nullable
   public HttpSession getSession(boolean create) {
-    throw new UnsupportedOperationException();
+    if (session != null || !create) {
+      return session;
+    }
+
+    if (sessionSupplier == null) {
+      throw new UnsupportedOperationException();
+    }
+
+    session = sessionSupplier.get();
+    return session;
   }
 
   @Override
diff --git a/lib/BUILD b/lib/BUILD
index 7aa9a45..f9ece52 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -179,250 +179,10 @@
 )
 
 java_library(
-    name = "flexmark",
+    name = "flexmark-all-lib",
     data = ["//lib:LICENSE-flexmark"],
     visibility = ["//visibility:public"],
-    exports = ["@flexmark//jar"],
-    runtime_deps = [
-        ":flexmark-ext-abbreviation",
-    ],
-)
-
-java_library(
-    name = "flexmark-ext-abbreviation",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-ext-abbreviation//jar"],
-    runtime_deps = [
-        ":flexmark-ext-anchorlink",
-    ],
-)
-
-java_library(
-    name = "flexmark-ext-anchorlink",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-ext-anchorlink//jar"],
-    runtime_deps = [
-        ":flexmark-ext-autolink",
-    ],
-)
-
-java_library(
-    name = "flexmark-ext-autolink",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-ext-autolink//jar"],
-    runtime_deps = [
-        ":flexmark-ext-definition",
-    ],
-)
-
-java_library(
-    name = "flexmark-ext-definition",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-ext-definition//jar"],
-    runtime_deps = [
-        ":flexmark-ext-emoji",
-    ],
-)
-
-java_library(
-    name = "flexmark-ext-emoji",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-ext-emoji//jar"],
-    runtime_deps = [
-        ":flexmark-ext-escaped-character",
-    ],
-)
-
-java_library(
-    name = "flexmark-ext-escaped-character",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-ext-escaped-character//jar"],
-    runtime_deps = [
-        ":flexmark-ext-footnotes",
-    ],
-)
-
-java_library(
-    name = "flexmark-ext-footnotes",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-ext-footnotes//jar"],
-    runtime_deps = [
-        ":flexmark-ext-gfm-issues",
-    ],
-)
-
-java_library(
-    name = "flexmark-ext-gfm-issues",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-ext-gfm-issues//jar"],
-    runtime_deps = [
-        ":flexmark-ext-gfm-strikethrough",
-    ],
-)
-
-java_library(
-    name = "flexmark-ext-gfm-strikethrough",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-ext-gfm-strikethrough//jar"],
-    runtime_deps = [
-        ":flexmark-ext-gfm-tables",
-    ],
-)
-
-java_library(
-    name = "flexmark-ext-gfm-tables",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-ext-gfm-tables//jar"],
-    runtime_deps = [
-        ":flexmark-ext-gfm-tasklist",
-    ],
-)
-
-java_library(
-    name = "flexmark-ext-gfm-tasklist",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-ext-gfm-tasklist//jar"],
-    runtime_deps = [
-        ":flexmark-ext-gfm-users",
-    ],
-)
-
-java_library(
-    name = "flexmark-ext-gfm-users",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-ext-gfm-users//jar"],
-    runtime_deps = [
-        ":flexmark-ext-ins",
-    ],
-)
-
-java_library(
-    name = "flexmark-ext-ins",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-ext-ins//jar"],
-    runtime_deps = [
-        ":flexmark-ext-jekyll-front-matter",
-    ],
-)
-
-java_library(
-    name = "flexmark-ext-jekyll-front-matter",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-ext-jekyll-front-matter//jar"],
-    runtime_deps = [
-        ":flexmark-ext-superscript",
-    ],
-)
-
-java_library(
-    name = "flexmark-ext-superscript",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-ext-superscript//jar"],
-    runtime_deps = [
-        ":flexmark-ext-tables",
-    ],
-)
-
-java_library(
-    name = "flexmark-ext-tables",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-ext-tables//jar"],
-    runtime_deps = [
-        ":flexmark-ext-toc",
-    ],
-)
-
-java_library(
-    name = "flexmark-ext-toc",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-ext-toc//jar"],
-    runtime_deps = [
-        ":flexmark-ext-typographic",
-    ],
-)
-
-java_library(
-    name = "flexmark-ext-typographic",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-ext-typographic//jar"],
-    runtime_deps = [
-        ":flexmark-ext-wikilink",
-    ],
-)
-
-java_library(
-    name = "flexmark-ext-wikilink",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-ext-wikilink//jar"],
-    runtime_deps = [
-        ":flexmark-ext-yaml-front-matter",
-    ],
-)
-
-java_library(
-    name = "flexmark-ext-yaml-front-matter",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-ext-yaml-front-matter//jar"],
-    runtime_deps = [
-        ":flexmark-formatter",
-    ],
-)
-
-java_library(
-    name = "flexmark-formatter",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-formatter//jar"],
-    runtime_deps = [
-        ":flexmark-html-parser",
-    ],
-)
-
-java_library(
-    name = "flexmark-html-parser",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-html-parser//jar"],
-    runtime_deps = [
-        ":flexmark-profile-pegdown",
-    ],
-)
-
-java_library(
-    name = "flexmark-profile-pegdown",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-profile-pegdown//jar"],
-    runtime_deps = [
-        ":flexmark-util",
-    ],
-)
-
-java_library(
-    name = "flexmark-util",
-    data = ["//lib:LICENSE-flexmark"],
-    visibility = ["//visibility:public"],
-    exports = ["@flexmark-util//jar"],
+    exports = ["@flexmark-all-lib//jar"],
 )
 
 java_library(
@@ -514,6 +274,7 @@
         ":icu4j",
         ":jsr305",
         ":protobuf",
+        "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
         "//lib/guice:javax_inject",
@@ -538,6 +299,16 @@
     exports = ["@icu4j//jar"],
 )
 
+java_library(
+    name = "roaringbitmap",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = [
+        "@roaringbitmap-shims//jar",
+        "@roaringbitmap//jar",
+    ],
+)
+
 sh_test(
     name = "nongoogle_test",
     srcs = ["nongoogle_test.sh"],
diff --git a/lib/flogger/BUILD b/lib/flogger/BUILD
index 35c3c62..a335586 100644
--- a/lib/flogger/BUILD
+++ b/lib/flogger/BUILD
@@ -5,6 +5,7 @@
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
     exports = [
+        "@flogger-google-extensions//jar",
         "@flogger-log4j-backend//jar",
         "@flogger-system-backend//jar",
         "@flogger//jar",
diff --git a/lib/gitiles/BUILD b/lib/gitiles/BUILD
index 6e03801..b91cc1f 100644
--- a/lib/gitiles/BUILD
+++ b/lib/gitiles/BUILD
@@ -52,7 +52,6 @@
 
 java_library(
     name = "prettify",
-    data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
-    exports = ["@prettify//jar"],
+    exports = ["@java-prettify"],
 )
diff --git a/lib/guice/BUILD b/lib/guice/BUILD
index f73984b..091dcad 100644
--- a/lib/guice/BUILD
+++ b/lib/guice/BUILD
@@ -6,6 +6,7 @@
     visibility = ["//visibility:public"],
     exports = [
         ":guice-library",
+        ":jakarta-inject",
         ":javax_inject",
     ],
 )
@@ -41,6 +42,13 @@
 )
 
 java_library(
+    name = "jakarta-inject",
+    data = ["//lib:LICENSE-Apache2.0"],
+    visibility = ["//visibility:public"],
+    exports = ["@jakarta-inject-api//jar"],
+)
+
+java_library(
     name = "javax_inject",
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
diff --git a/lib/nongoogle_test.sh b/lib/nongoogle_test.sh
index 51c50bf..78f4852 100755
--- a/lib/nongoogle_test.sh
+++ b/lib/nongoogle_test.sh
@@ -18,6 +18,7 @@
 eddsa
 error-prone-annotations
 flogger
+flogger-google-extensions
 flogger-log4j-backend
 flogger-system-backend
 guava
diff --git a/modules/java-prettify b/modules/java-prettify
new file mode 160000
index 0000000..32fa081
--- /dev/null
+++ b/modules/java-prettify
@@ -0,0 +1 @@
+Subproject commit 32fa081a797a97beaf77a4f2efca26c39168e72f
diff --git a/modules/jgit b/modules/jgit
index 1cd87ab..c824610 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 1cd87ab79065b78a0774f20f1bfd522747c37c15
+Subproject commit c824610abba794a1f8f13d6ff2ec1c09590ce697
diff --git a/package.json b/package.json
index 5c33663..7ff0169 100644
--- a/package.json
+++ b/package.json
@@ -1,36 +1,37 @@
 {
   "name": "gerrit",
-  "version": "3.1.0-SNAPSHOT",
+  "version": "3.9.0-SNAPSHOT",
   "description": "Gerrit Code Review",
   "dependencies": {
-    "@bazel/concatjs": "^5.5.0",
-    "@bazel/rollup": "^5.5.0",
-    "@bazel/terser": "^5.5.0",
-    "@bazel/typescript": "^5.5.0"
+    "@bazel/concatjs": "^5.8.1",
+    "@bazel/rollup": "^5.8.1",
+    "@bazel/terser": "^5.8.1",
+    "@bazel/typescript": "^5.8.1",
+    "@typescript-eslint/parser": "^5.62.0"
   },
   "devDependencies": {
     "@koa/cors": "^5.0.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",
+    "@types/page": "^1.11.6",
+    "@typescript-eslint/eslint-plugin": "^5.62.0",
+    "@web/dev-server": "^0.1.38",
+    "@web/dev-server-esbuild": "^0.3.6",
+    "eslint": "^8.49.0",
     "eslint-config-google": "^0.14.0",
-    "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-html": "^7.1.0",
+    "eslint-plugin-import": "^2.28.1",
+    "eslint-plugin-jsdoc": "^44.2.7",
+    "eslint-plugin-lit": "^1.9.1",
     "eslint-plugin-node": "^11.1.0",
-    "eslint-plugin-prettier": "^4.0.0",
-    "eslint-plugin-regex": "^1.9.0",
-    "gts": "^3.1.0",
+    "eslint-plugin-prettier": "^4.2.1",
+    "eslint-plugin-regex": "^1.10.0",
+    "gts": "^3.1.1",
     "lit-analyzer": "^1.2.1",
     "npm-run-all": "^4.1.5",
-    "prettier": "2.6.2",
-    "rollup": "^2.45.2",
-    "terser": "^5.6.1",
+    "prettier": "^2.8.8",
+    "rollup": "^2.79.1",
+    "terser": "~5.8.0",
     "ts-lit-plugin": "^1.2.1",
-    "typescript": "^4.7.2"
+    "typescript": "^4.9.5"
   },
   "scripts": {
     "setup": "yarn && yarn --cwd=polygerrit-ui && yarn --cwd=polygerrit-ui/app",
@@ -58,9 +59,9 @@
     "url": "https://gerrit.googlesource.com/gerrit"
   },
   "resolutions": {
-    "eslint": "^8.16.0",
-    "@typescript-eslint/eslint-plugin": "^5.27.0",
-    "@typescript-eslint/parser": "^5.27.0"
+    "eslint": "^8.49.0",
+    "@typescript-eslint/eslint-plugin": "^5.62.0",
+    "@typescript-eslint/parser": "^5.62.0"
   },
   "author": "",
   "license": "Apache-2.0"
diff --git a/plugins/.eslintrc.js b/plugins/.eslintrc.js
index 149a31e..920ec2a 100644
--- a/plugins/.eslintrc.js
+++ b/plugins/.eslintrc.js
@@ -186,8 +186,6 @@
     'jsdoc/implements-on-classes': 2,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-match-description
     'jsdoc/match-description': 0,
-    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-newline-after-description
-    'jsdoc/newline-after-description': 2,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-no-types
     'jsdoc/no-types': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-no-undefined-types
diff --git a/plugins/BUILD b/plugins/BUILD
index 39560c5..b9c51e3 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -62,6 +62,7 @@
     "//java/com/google/gerrit/server/data",
     "//java/com/google/gerrit/server/git/receive",
     "//java/com/google/gerrit/server/logging",
+    "//java/com/google/gerrit/server/rules/prolog",
     "//java/com/google/gerrit/server/schema",
     "//java/com/google/gerrit/server/util/time",
     "//java/com/google/gerrit/proto",
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index 6f91658..ce9838b 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit 6f916580c3f26ecfd9b2a10d96cb1c0530103bc6
+Subproject commit ce9838b8795338877f74c7f3b61c7c4526a279e6
diff --git a/plugins/delete-project b/plugins/delete-project
index 9378a0e..4875495 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 9378a0e55daf9e24b8863a2605e6a1f1828f73a1
+Subproject commit 48754950c0f7781aaf169fef378e595e7d822e3a
diff --git a/plugins/download-commands b/plugins/download-commands
index b90e523..978e803 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit b90e523f589a0e2902823233010163f453243926
+Subproject commit 978e803c87416eb9e96236446b15b167017c0385
diff --git a/plugins/gitiles b/plugins/gitiles
index 20f65c2..4e8bd70 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit 20f65c2067b9190d1c85fbf61e5d72edf4493724
+Subproject commit 4e8bd706e87eb11e3cfe2bfa9bbcb29020f39482
diff --git a/plugins/package.json b/plugins/package.json
index 504fc17..4a4bf03 100644
--- a/plugins/package.json
+++ b/plugins/package.json
@@ -3,39 +3,39 @@
   "description": "Gerrit Code Review - frontend plugin dependencies, each plugin may depend on a subset of these",
   "browser": true,
   "dependencies": {
-    "@gerritcodereview/typescript-api": "3.8.0",
-    "@polymer/decorators": "^3.0.0",
-    "@polymer/polymer": "^3.4.1",
-    "@open-wc/testing": "^3.1.6",
-    "@web/dev-server-esbuild": "^0.3.2",
-    "@web/test-runner": "^0.14.0",
-    "@codemirror/autocomplete": "^6.5.1",
-    "@codemirror/commands": "^6.2.3",
-    "@codemirror/legacy-modes": "^6.3.2",
+    "@codemirror/autocomplete": "^6.9.1",
+    "@codemirror/commands": "^6.2.5",
     "@codemirror/lang-cpp": "^6.0.2",
-    "@codemirror/lang-css": "^6.2.0",
-    "@codemirror/lang-html": "^6.4.3",
+    "@codemirror/lang-css": "^6.2.1",
+    "@codemirror/lang-html": "^6.4.6",
     "@codemirror/lang-java": "^6.0.1",
-    "@codemirror/lang-javascript": "^6.1.7",
+    "@codemirror/lang-javascript": "^6.2.1",
     "@codemirror/lang-json": "^6.0.1",
     "@codemirror/lang-less": "^6.0.0",
-    "@codemirror/lang-markdown": "^6.1.1",
+    "@codemirror/lang-markdown": "^6.2.1",
     "@codemirror/lang-php": "^6.0.1",
-    "@codemirror/lang-python": "^6.1.2",
+    "@codemirror/lang-python": "^6.1.3",
     "@codemirror/lang-rust": "^6.0.1",
-    "@codemirror/lang-sass": "^6.0.1",
-    "@codemirror/lang-sql": "^6.4.1",
+    "@codemirror/lang-sass": "^6.0.2",
+    "@codemirror/lang-sql": "^6.5.4",
     "@codemirror/lang-xml": "^6.0.2",
-    "@codemirror/language": "^6.6.0",
-    "@codemirror/language-data": "^6.3.0",
-    "@codemirror/lint": "^6.2.1",
-    "@codemirror/search": "^6.4.0",
-    "@codemirror/state": "^6.2.0",
-    "@codemirror/view": "^6.10.0",
-    "lit": "^2.2.3",
+    "@codemirror/language": "^6.9.1",
+    "@codemirror/language-data": "^6.3.1",
+    "@codemirror/legacy-modes": "^6.3.3",
+    "@codemirror/lint": "^6.4.2",
+    "@codemirror/search": "^6.5.4",
+    "@codemirror/state": "^6.2.1",
+    "@codemirror/view": "^6.20.2",
+    "@gerritcodereview/typescript-api": "3.9.1",
+    "@open-wc/testing": "^3.2.0",
+    "@polymer/decorators": "^3.0.0",
+    "@polymer/polymer": "^3.5.1",
+    "@web/dev-server-esbuild": "^0.3.6",
+    "@web/test-runner": "^0.15.3",
+    "lit": "^3.0.0",
     "rxjs": "^6.6.7",
-    "sinon": "^13.0.0"
+    "sinon": "^13.0.2"
   },
   "license": "Apache-2.0",
   "private": true
-}
+}
\ No newline at end of file
diff --git a/plugins/yarn.lock b/plugins/yarn.lock
index 3f53453..045fcb5 100644
--- a/plugins/yarn.lock
+++ b/plugins/yarn.lock
@@ -2,41 +2,50 @@
 # yarn lockfile v1
 
 
+"@75lb/deep-merge@^1.1.1":
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/@75lb/deep-merge/-/deep-merge-1.1.1.tgz#3b06155b90d34f5f8cc2107d796f1853ba02fd6d"
+  integrity sha512-xvgv6pkMGBA6GwdyJbNAnDmfAIR/DfWhrj9jgWh3TY7gRm3KO46x/GPjRg6wJ0nOepwqrNxFfojebh0Df4h4Tw==
+  dependencies:
+    lodash.assignwith "^4.2.0"
+    typical "^7.1.1"
+
 "@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==
+  version "7.22.13"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.13.tgz#e3c1c099402598483b7a8c46a721d1038803755e"
+  integrity sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==
   dependencies:
-    "@babel/highlight" "^7.18.6"
+    "@babel/highlight" "^7.22.13"
+    chalk "^2.4.2"
 
-"@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/helper-validator-identifier@^7.22.20":
+  version "7.22.20"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0"
+  integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==
 
-"@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==
+"@babel/highlight@^7.22.13":
+  version "7.22.20"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.20.tgz#4ca92b71d80554b01427815e06f2df965b9c1f54"
+  integrity sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==
   dependencies:
-    "@babel/helper-validator-identifier" "^7.18.6"
-    chalk "^2.0.0"
+    "@babel/helper-validator-identifier" "^7.22.20"
+    chalk "^2.4.2"
     js-tokens "^4.0.0"
 
-"@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.3.2", "@codemirror/autocomplete@^6.5.1":
-  version "6.5.1"
-  resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.5.1.tgz#539cfff291dbffd3841cba078b222cea28ff7eda"
-  integrity sha512-/Sv9yJmqyILbZ26U4LBHnAtbikuVxWUp+rQ8BXuRGtxZfbfKOY/WPbsUtvSP2h0ZUZMlkxV/hqbKRFzowlA6xw==
+"@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.3.2", "@codemirror/autocomplete@^6.7.1", "@codemirror/autocomplete@^6.9.1":
+  version "6.9.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.9.1.tgz#e0989c6a33a37604b5d2c896dcca7562ae3d7c61"
+  integrity sha512-yma56tqD7khIZK4gy4X5lX3/k5ArMiCGat7HEWRF/8L2kqOjVdp2qKZqpcJjwTIjSj6fqKAHqi7IjtH3QFE+Bw==
   dependencies:
     "@codemirror/language" "^6.0.0"
     "@codemirror/state" "^6.0.0"
-    "@codemirror/view" "^6.6.0"
+    "@codemirror/view" "^6.17.0"
     "@lezer/common" "^1.0.0"
 
-"@codemirror/commands@^6.2.3":
-  version "6.2.3"
-  resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.2.3.tgz#ec476fd588f7a4333f54584d4783dd3862befe3b"
-  integrity sha512-9uf0g9m2wZyrIim1SavcxMdwsu8wc/y5uSw6JRUBYIGWrN+RY4vSru/BqB+MyNWqx4C2uRhQ/Kh7Pw8lAyT3qQ==
+"@codemirror/commands@^6.2.5":
+  version "6.2.5"
+  resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.2.5.tgz#e889f93f9cc85b32f6b2844d85d08688f695a6b8"
+  integrity sha512-dSi7ow2P2YgPBZflR9AJoaTHvqmeGIgkhignYMd5zK5y6DANTvxKxp6eMEpIDUJkRAaOY/TFZ4jP1ADIO/GLVA==
   dependencies:
     "@codemirror/language" "^6.0.0"
     "@codemirror/state" "^6.2.0"
@@ -44,15 +53,16 @@
     "@lezer/common" "^1.0.0"
 
 "@codemirror/lang-angular@^0.1.0":
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-angular/-/lang-angular-0.1.0.tgz#1054747c8196357a2aee2b9c36f0f6de9a6ffef9"
-  integrity sha512-vTjoHjzJmLrrMFmf/tojwp+O0P+R9mgWtjjaKDNDoY58PzOPg7ldMEBqIzABBc+/2mYPD85SG7O5byfBxc83eA==
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-angular/-/lang-angular-0.1.2.tgz#a3f565297842ad60caf2a0bf6f6137c13d19a666"
+  integrity sha512-Nq7lmx9SU+JyoaRcs6SaJs7uAmW2W06HpgJVQYeZptVGNWDzDvzhjwVb/ZuG1rwTlOocY4Y9GwNOBuKCeJbKtw==
   dependencies:
     "@codemirror/lang-html" "^6.0.0"
     "@codemirror/lang-javascript" "^6.1.2"
     "@codemirror/language" "^6.0.0"
     "@lezer/common" "^1.0.0"
     "@lezer/highlight" "^1.0.0"
+    "@lezer/lr" "^1.3.3"
 
 "@codemirror/lang-cpp@^6.0.0", "@codemirror/lang-cpp@^6.0.2":
   version "6.0.2"
@@ -62,10 +72,10 @@
     "@codemirror/language" "^6.0.0"
     "@lezer/cpp" "^1.0.0"
 
-"@codemirror/lang-css@^6.0.0", "@codemirror/lang-css@^6.1.1", "@codemirror/lang-css@^6.2.0":
-  version "6.2.0"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-css/-/lang-css-6.2.0.tgz#f84f9da392099432445c75e32fdac63ae572315f"
-  integrity sha512-oyIdJM29AyRPM3+PPq1I2oIk8NpUfEN3kAM05XWDDs6o3gSneIKaVJifT2P+fqONLou2uIgXynFyMUDQvo/szA==
+"@codemirror/lang-css@^6.0.0", "@codemirror/lang-css@^6.2.0", "@codemirror/lang-css@^6.2.1":
+  version "6.2.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-css/-/lang-css-6.2.1.tgz#5dc0a43b8e3c31f6af7aabd55ff07fe9aef2a227"
+  integrity sha512-/UNWDNV5Viwi/1lpr/dIXJNWiwDxpw13I4pTUAsNxZdg6E0mI2kTQb0P2iHczg1Tu+H4EBgJR+hYhKiHKko7qg==
   dependencies:
     "@codemirror/autocomplete" "^6.0.0"
     "@codemirror/language" "^6.0.0"
@@ -73,17 +83,17 @@
     "@lezer/common" "^1.0.2"
     "@lezer/css" "^1.0.0"
 
-"@codemirror/lang-html@^6.0.0", "@codemirror/lang-html@^6.4.3":
-  version "6.4.3"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-html/-/lang-html-6.4.3.tgz#dec78f76d9d0261cbe9f2a3a247a1b546327f700"
-  integrity sha512-VKzQXEC8nL69Jg2hvAFPBwOdZNvL8tMFOrdFwWpU+wc6a6KEkndJ/19R5xSaglNX6v2bttm8uIEFYxdQDcIZVQ==
+"@codemirror/lang-html@^6.0.0", "@codemirror/lang-html@^6.4.6":
+  version "6.4.6"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-html/-/lang-html-6.4.6.tgz#25c1c71591da80e75dceb67ceeab41e87bde29a5"
+  integrity sha512-E4C8CVupBksXvgLSme/zv31x91g06eZHSph7NczVxZW+/K+3XgJGWNT//2WLzaKSBoxpAjaOi5ZnPU1SHhjh3A==
   dependencies:
     "@codemirror/autocomplete" "^6.0.0"
     "@codemirror/lang-css" "^6.0.0"
     "@codemirror/lang-javascript" "^6.0.0"
     "@codemirror/language" "^6.4.0"
     "@codemirror/state" "^6.0.0"
-    "@codemirror/view" "^6.2.2"
+    "@codemirror/view" "^6.17.0"
     "@lezer/common" "^1.0.0"
     "@lezer/css" "^1.1.0"
     "@lezer/html" "^1.3.0"
@@ -96,16 +106,16 @@
     "@codemirror/language" "^6.0.0"
     "@lezer/java" "^1.0.0"
 
-"@codemirror/lang-javascript@^6.0.0", "@codemirror/lang-javascript@^6.1.2", "@codemirror/lang-javascript@^6.1.7":
-  version "6.1.7"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-javascript/-/lang-javascript-6.1.7.tgz#e39fb9757b1cf47de432e4244d18ca5284a73a58"
-  integrity sha512-KXKqxlZ4W6t5I7i2ScmITUD3f/F5Cllk3kj0De9P9mFeYVfhOVOWuDLgYiLpk357u7Xh4dhqjJAnsNPPoTLghQ==
+"@codemirror/lang-javascript@^6.0.0", "@codemirror/lang-javascript@^6.1.2", "@codemirror/lang-javascript@^6.2.1":
+  version "6.2.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-javascript/-/lang-javascript-6.2.1.tgz#8068d44365d13cdb044936fb4e3483301c12ef95"
+  integrity sha512-jlFOXTejVyiQCW3EQwvKH0m99bUYIw40oPmFjSX2VS78yzfe0HELZ+NEo9Yfo1MkGRpGlj3Gnu4rdxV1EnAs5A==
   dependencies:
     "@codemirror/autocomplete" "^6.0.0"
     "@codemirror/language" "^6.6.0"
     "@codemirror/lint" "^6.0.0"
     "@codemirror/state" "^6.0.0"
-    "@codemirror/view" "^6.0.0"
+    "@codemirror/view" "^6.17.0"
     "@lezer/common" "^1.0.0"
     "@lezer/javascript" "^1.0.0"
 
@@ -118,20 +128,21 @@
     "@lezer/json" "^1.0.0"
 
 "@codemirror/lang-less@^6.0.0":
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-less/-/lang-less-6.0.0.tgz#47ac36242f45bcc211dbcbce11e10f3b249519c9"
-  integrity sha512-hQVj+AxcUW/LybRkwaOope8K8+U6bjWH91t0tW8MMok33Y65xo+Wx0t1BaXi3Iuo6CgJ4tW7Rz09cfNwloIdNA==
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-less/-/lang-less-6.0.1.tgz#fef10e8dbcd07055b815c3928233a05a8549181e"
+  integrity sha512-ABcsKBjLbyPZwPR5gePpc8jEKCQrFF4pby2WlMVdmJOOr7OWwwyz8DZonPx/cKDE00hfoSLc8F7yAcn/d6+rTQ==
   dependencies:
     "@codemirror/lang-css" "^6.2.0"
     "@codemirror/language" "^6.0.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
-"@codemirror/lang-markdown@^6.0.0", "@codemirror/lang-markdown@^6.1.1":
-  version "6.1.1"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-markdown/-/lang-markdown-6.1.1.tgz#ff3cdd339c277f6a02d08eb12f1090977873e771"
-  integrity sha512-n87Ms6Y5UYb1UkFu8sRzTLfq/yyF1y2AYiWvaVdbBQi5WDj1tFk5N+AKA+WC0Jcjc1VxvrCCM0iizjdYYi9sFQ==
+"@codemirror/lang-markdown@^6.0.0", "@codemirror/lang-markdown@^6.2.1":
+  version "6.2.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-markdown/-/lang-markdown-6.2.1.tgz#2d9e14a579e9a17c164902dcc0d771e86a6803d1"
+  integrity sha512-Tpk1+CllQ/KU27AYixsxvtqqQS2xYfLEUiBEyyzO+Y9/0LfI2oB1qM2cMhy0D7oRnG0ZSAy9qcYniZc9VtlIxg==
   dependencies:
+    "@codemirror/autocomplete" "^6.7.1"
     "@codemirror/lang-html" "^6.0.0"
     "@codemirror/language" "^6.3.0"
     "@codemirror/state" "^6.0.0"
@@ -150,14 +161,14 @@
     "@lezer/common" "^1.0.0"
     "@lezer/php" "^1.0.0"
 
-"@codemirror/lang-python@^6.0.0", "@codemirror/lang-python@^6.1.2":
-  version "6.1.2"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-python/-/lang-python-6.1.2.tgz#cabb57529679981f170491833dbf798576e7ab18"
-  integrity sha512-nbQfifLBZstpt6Oo4XxA2LOzlSp4b/7Bc5cmodG1R+Cs5PLLCTUvsMNWDnziiCfTOG/SW1rVzXq/GbIr6WXlcw==
+"@codemirror/lang-python@^6.0.0", "@codemirror/lang-python@^6.1.3":
+  version "6.1.3"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-python/-/lang-python-6.1.3.tgz#47b8d9fb42eb4482317843e519c6c211accacb62"
+  integrity sha512-S9w2Jl74hFlD5nqtUMIaXAq9t5WlM0acCkyuQWUUSvZclk1sV+UfnpFiZzuZSG+hfEaOmxKR5UxY/Uxswn7EhQ==
   dependencies:
     "@codemirror/autocomplete" "^6.3.2"
-    "@codemirror/language" "^6.0.0"
-    "@lezer/python" "^1.0.0"
+    "@codemirror/language" "^6.8.0"
+    "@lezer/python" "^1.1.4"
 
 "@codemirror/lang-rust@^6.0.0", "@codemirror/lang-rust@^6.0.1":
   version "6.0.1"
@@ -167,21 +178,21 @@
     "@codemirror/language" "^6.0.0"
     "@lezer/rust" "^1.0.0"
 
-"@codemirror/lang-sass@^6.0.0", "@codemirror/lang-sass@^6.0.1":
-  version "6.0.1"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-sass/-/lang-sass-6.0.1.tgz#e390f427c8601175f155046e142371c3c4fb718c"
-  integrity sha512-USy9zqtdLYxSuqq0s4peMoQi+BDzyOyO7chUzli+X2xVCjmBhc3CsWQ4kkDU0NYtCHHFQRkcFO8770eaOwZqfw==
+"@codemirror/lang-sass@^6.0.0", "@codemirror/lang-sass@^6.0.2":
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz#38c1b0a1326cc9f5cb2741d2cd51cfbcd7abc0b2"
+  integrity sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==
   dependencies:
-    "@codemirror/lang-css" "^6.1.1"
+    "@codemirror/lang-css" "^6.2.0"
     "@codemirror/language" "^6.0.0"
     "@codemirror/state" "^6.0.0"
     "@lezer/common" "^1.0.2"
     "@lezer/sass" "^1.0.0"
 
-"@codemirror/lang-sql@^6.0.0", "@codemirror/lang-sql@^6.4.1":
-  version "6.4.1"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-sql/-/lang-sql-6.4.1.tgz#e680fe8c12e5902a29fd952207bf454ae02b3bdc"
-  integrity sha512-PFB56L+A0WGY35uRya+Trt5g19V9k2V9X3c55xoFW4RgiATr/yLqWsbbnEsdxuMn5tLpuikp7Kmj9smRsqBXAg==
+"@codemirror/lang-sql@^6.0.0", "@codemirror/lang-sql@^6.5.4":
+  version "6.5.4"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-sql/-/lang-sql-6.5.4.tgz#cb1f0140a22d9d502d93d7b91390c2e0becedce5"
+  integrity sha512-5Gq7fYtT/5HbNyIG7a8vYaqOYQU3JbgtBe3+derkrFUXRVcjkf8WVgz++PIbMFAQsOFMDdDR+uiNM8ZRRuXH+w==
   dependencies:
     "@codemirror/autocomplete" "^6.0.0"
     "@codemirror/language" "^6.0.0"
@@ -190,9 +201,9 @@
     "@lezer/lr" "^1.0.0"
 
 "@codemirror/lang-vue@^0.1.1":
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/@codemirror/lang-vue/-/lang-vue-0.1.1.tgz#79567fb3be3f411354cd135af59d67f956cdb042"
-  integrity sha512-GIfc/MemCFKUdNSYGTFZDN8XsD2z0DUY7DgrK34on0dzdZ/CawZbi+SADYfVzWoPPdxngHzLhqlR5pSOqyPCvA==
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/@codemirror/lang-vue/-/lang-vue-0.1.2.tgz#50aec87b93ba8a6b0742a24cbab566b3989ee6ca"
+  integrity sha512-D4YrefiRBAr+CfEIM4S3yvGSbYW+N69mttIfGMEf7diHpRbmygDxS+R/5xSqjgtkY6VO6qmUrre1GkRcWeZa9A==
   dependencies:
     "@codemirror/lang-html" "^6.0.0"
     "@codemirror/lang-javascript" "^6.1.2"
@@ -221,10 +232,10 @@
     "@lezer/common" "^1.0.0"
     "@lezer/xml" "^1.0.0"
 
-"@codemirror/language-data@^6.3.0":
-  version "6.3.0"
-  resolved "https://registry.yarnpkg.com/@codemirror/language-data/-/language-data-6.3.0.tgz#058365fc2e857eb48810ed92134ee469d9a9bba6"
-  integrity sha512-D9tOZS38mK59jDs1Flqe8GgCdUAYI339SqBdwHJZwxgyXHsBc8RIhAlz2oXWGpvZeP/kVHy9LVfoBFgO02mx7w==
+"@codemirror/language-data@^6.3.1":
+  version "6.3.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/language-data/-/language-data-6.3.1.tgz#795ec09e04260868070296241363d70f4060bb36"
+  integrity sha512-p6jhJmvhGe1TG1EGNhwH7nFWWFSTJ8NDKnB2fVx5g3t+PpO0+63R7GJNxjS0TmmH3cdMxZbzejsik+rlEh1EyQ==
   dependencies:
     "@codemirror/lang-angular" "^0.1.0"
     "@codemirror/lang-cpp" "^6.0.0"
@@ -233,6 +244,7 @@
     "@codemirror/lang-java" "^6.0.0"
     "@codemirror/lang-javascript" "^6.0.0"
     "@codemirror/lang-json" "^6.0.0"
+    "@codemirror/lang-less" "^6.0.0"
     "@codemirror/lang-markdown" "^6.0.0"
     "@codemirror/lang-php" "^6.0.0"
     "@codemirror/lang-python" "^6.0.0"
@@ -245,61 +257,166 @@
     "@codemirror/language" "^6.0.0"
     "@codemirror/legacy-modes" "^6.1.0"
 
-"@codemirror/language@^6.0.0", "@codemirror/language@^6.3.0", "@codemirror/language@^6.4.0", "@codemirror/language@^6.6.0":
-  version "6.6.0"
-  resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.6.0.tgz#2204407174a38a68053715c19e28ad61f491779f"
-  integrity sha512-cwUd6lzt3MfNYOobdjf14ZkLbJcnv4WtndYaoBkbor/vF+rCNguMPK0IRtvZJG4dsWiaWPcK8x1VijhvSxnstg==
+"@codemirror/language@^6.0.0", "@codemirror/language@^6.3.0", "@codemirror/language@^6.4.0", "@codemirror/language@^6.6.0", "@codemirror/language@^6.8.0", "@codemirror/language@^6.9.1":
+  version "6.9.1"
+  resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.9.1.tgz#97e2c3e44cf4ff152add865ed7ecec73868446a4"
+  integrity sha512-lWRP3Y9IUdOms6DXuBpoWwjkR7yRmnS0hKYCbSfPz9v6Em1A1UCRujAkDiCrdYfs1Z0Eu4dGtwovNPStIfkgNA==
   dependencies:
     "@codemirror/state" "^6.0.0"
     "@codemirror/view" "^6.0.0"
-    "@lezer/common" "^1.0.0"
+    "@lezer/common" "^1.1.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
     style-mod "^4.0.0"
 
-"@codemirror/legacy-modes@^6.1.0", "@codemirror/legacy-modes@^6.3.2":
-  version "6.3.2"
-  resolved "https://registry.yarnpkg.com/@codemirror/legacy-modes/-/legacy-modes-6.3.2.tgz#d5616b453f38866717437b51c16bde1ae3f011ec"
-  integrity sha512-ki5sqNKWzKi5AKvpVE6Cna4Q+SgxYuYVLAZFSsMjGBWx5qSVa+D+xipix65GS3f2syTfAD9pXKMX4i4p49eneQ==
+"@codemirror/legacy-modes@^6.1.0", "@codemirror/legacy-modes@^6.3.3":
+  version "6.3.3"
+  resolved "https://registry.yarnpkg.com/@codemirror/legacy-modes/-/legacy-modes-6.3.3.tgz#d7827c76c9533efdc76f7d0a0fc866f5acd4b764"
+  integrity sha512-X0Z48odJ0KIoh/HY8Ltz75/4tDYc9msQf1E/2trlxFaFFhgjpVHjZ/BCXe1Lk7s4Gd67LL/CeEEHNI+xHOiESg==
   dependencies:
     "@codemirror/language" "^6.0.0"
 
-"@codemirror/lint@^6.0.0", "@codemirror/lint@^6.2.1":
+"@codemirror/lint@^6.0.0", "@codemirror/lint@^6.4.2":
+  version "6.4.2"
+  resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.4.2.tgz#c13be5320bde9707efdc94e8bcd3c698abae0b92"
+  integrity sha512-wzRkluWb1ptPKdzlsrbwwjYCPLgzU6N88YBAmlZi8WFyuiEduSd05MnJYNogzyc8rPK7pj6m95ptUApc8sHKVA==
+  dependencies:
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.0.0"
+    crelt "^1.0.5"
+
+"@codemirror/search@^6.5.4":
+  version "6.5.4"
+  resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.5.4.tgz#54005697bf581f7dccbbb4a0c34d3a7aa25a513a"
+  integrity sha512-YoTrvjv9e8EbPs58opjZKyJ3ewFrVSUzQ/4WXlULQLSDDr1nGPJ67mMXFNNVYwdFhybzhrzrtqgHmtpJwIF+8g==
+  dependencies:
+    "@codemirror/state" "^6.0.0"
+    "@codemirror/view" "^6.0.0"
+    crelt "^1.0.5"
+
+"@codemirror/state@^6.0.0", "@codemirror/state@^6.1.4", "@codemirror/state@^6.2.0", "@codemirror/state@^6.2.1":
   version "6.2.1"
-  resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.2.1.tgz#654581d8cc293c315ecfa5c9d61d78c52bbd9ccd"
-  integrity sha512-y1muai5U/uUPAGRyHMx9mHuHLypPcHWxzlZGknp/U5Mdb5Ol8Q5ZLp67UqyTbNFJJ3unVxZ8iX3g1fMN79S1JQ==
-  dependencies:
-    "@codemirror/state" "^6.0.0"
-    "@codemirror/view" "^6.0.0"
-    crelt "^1.0.5"
+  resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.2.1.tgz#6dc8d8e5abb26b875e3164191872d69a5e85bd73"
+  integrity sha512-RupHSZ8+OjNT38zU9fKH2sv+Dnlr8Eb8sl4NOnnqz95mCFTZUaiRP8Xv5MeeaG0px2b8Bnfe7YGwCV3nsBhbuw==
 
-"@codemirror/search@^6.4.0":
-  version "6.4.0"
-  resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.4.0.tgz#2b256a9e0eaa9317fb48e3cc81eb2735360a59b4"
-  integrity sha512-zMDgaBXah+nMLK2dHz9GdCnGbQu+oaGRXS1qviqNZkvOCv/whp5XZFyoikLp/23PM9RBcbuKUUISUmQHM1eRHw==
-  dependencies:
-    "@codemirror/state" "^6.0.0"
-    "@codemirror/view" "^6.0.0"
-    crelt "^1.0.5"
-
-"@codemirror/state@^6.0.0", "@codemirror/state@^6.1.4", "@codemirror/state@^6.2.0":
-  version "6.2.0"
-  resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.2.0.tgz#a0fb08403ced8c2a68d1d0acee926bd20be922f2"
-  integrity sha512-69QXtcrsc3RYtOtd+GsvczJ319udtBf1PTrr2KbLWM/e2CXUPnh0Nz9AUo8WfhSQ7GeL8dPVNUmhQVgpmuaNGA==
-
-"@codemirror/view@^6.0.0", "@codemirror/view@^6.10.0", "@codemirror/view@^6.2.2", "@codemirror/view@^6.6.0":
-  version "6.10.0"
-  resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.10.0.tgz#40bb39f391955db8960337a9e80fd7564f8915e2"
-  integrity sha512-Oea3rvE4JQLMmLsy2b54yxXQJgJM9xKpUQIpF/LGgKUTH2lA06GAmEtKKWn5OUnbW3jrH1hHeUd8DJEgePMOeQ==
+"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.20.2":
+  version "6.20.2"
+  resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.20.2.tgz#77c8e2801cb8c324740780a9f3ab19a15096a51a"
+  integrity sha512-tZ9F0UZU2P3eTRtgljg3DaCOTn2FIjQU/ktTCjSz9/6he3GHDNxSCDAPidMtF+09r23o0h9H/5U7xibtUuEgdg==
   dependencies:
     "@codemirror/state" "^6.1.4"
-    style-mod "^4.0.0"
+    style-mod "^4.1.0"
     w3c-keyname "^2.2.4"
 
-"@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==
+"@esbuild/android-arm64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz#bafb75234a5d3d1b690e7c2956a599345e84a2fd"
+  integrity sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==
+
+"@esbuild/android-arm@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.17.19.tgz#5898f7832c2298bc7d0ab53701c57beb74d78b4d"
+  integrity sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==
+
+"@esbuild/android-x64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.17.19.tgz#658368ef92067866d95fb268719f98f363d13ae1"
+  integrity sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==
+
+"@esbuild/darwin-arm64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz#584c34c5991b95d4d48d333300b1a4e2ff7be276"
+  integrity sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==
+
+"@esbuild/darwin-x64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz#7751d236dfe6ce136cce343dce69f52d76b7f6cb"
+  integrity sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==
+
+"@esbuild/freebsd-arm64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz#cacd171665dd1d500f45c167d50c6b7e539d5fd2"
+  integrity sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==
+
+"@esbuild/freebsd-x64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz#0769456eee2a08b8d925d7c00b79e861cb3162e4"
+  integrity sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==
+
+"@esbuild/linux-arm64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz#38e162ecb723862c6be1c27d6389f48960b68edb"
+  integrity sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==
+
+"@esbuild/linux-arm@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz#1a2cd399c50040184a805174a6d89097d9d1559a"
+  integrity sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==
+
+"@esbuild/linux-ia32@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz#e28c25266b036ce1cabca3c30155222841dc035a"
+  integrity sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==
+
+"@esbuild/linux-loong64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz#0f887b8bb3f90658d1a0117283e55dbd4c9dcf72"
+  integrity sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==
+
+"@esbuild/linux-mips64el@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz#f5d2a0b8047ea9a5d9f592a178ea054053a70289"
+  integrity sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==
+
+"@esbuild/linux-ppc64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz#876590e3acbd9fa7f57a2c7d86f83717dbbac8c7"
+  integrity sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==
+
+"@esbuild/linux-riscv64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz#7f49373df463cd9f41dc34f9b2262d771688bf09"
+  integrity sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==
+
+"@esbuild/linux-s390x@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz#e2afd1afcaf63afe2c7d9ceacd28ec57c77f8829"
+  integrity sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==
+
+"@esbuild/linux-x64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz#8a0e9738b1635f0c53389e515ae83826dec22aa4"
+  integrity sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==
+
+"@esbuild/netbsd-x64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz#c29fb2453c6b7ddef9a35e2c18b37bda1ae5c462"
+  integrity sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==
+
+"@esbuild/openbsd-x64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz#95e75a391403cb10297280d524d66ce04c920691"
+  integrity sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==
+
+"@esbuild/sunos-x64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz#722eaf057b83c2575937d3ffe5aeb16540da7273"
+  integrity sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==
+
+"@esbuild/win32-arm64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz#9aa9dc074399288bdcdd283443e9aeb6b9552b6f"
+  integrity sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==
+
+"@esbuild/win32-ia32@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz#95ad43c62ad62485e210f6299c7b2571e48d2b03"
+  integrity sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==
+
+"@esbuild/win32-x64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz#8cfaf2ff603e9aabb910e9c0558c26cf32744061"
+  integrity sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==
 
 "@esm-bundle/chai@^4.3.4-fix.0":
   version "4.3.4-fix.0"
@@ -308,83 +425,101 @@
   dependencies:
     "@types/chai" "^4.2.12"
 
-"@gerritcodereview/typescript-api@3.8.0":
-  version "3.8.0"
-  resolved "https://registry.yarnpkg.com/@gerritcodereview/typescript-api/-/typescript-api-3.8.0.tgz#2e418b814d7451c40365b2dc4f88e9965ece0769"
-  integrity sha512-wUkIWUx99Rj1vxRYQISxyzN0nplqu7t5sRDyJ8R3yNNkvALQAMC6Whj63qzCsZsymVFzC5up3y+ZVxaeh7b+xA==
+"@gerritcodereview/typescript-api@3.9.1":
+  version "3.9.1"
+  resolved "https://registry.yarnpkg.com/@gerritcodereview/typescript-api/-/typescript-api-3.9.1.tgz#c532e9a39e3d5f16f6a5cb5691988c04cd477531"
+  integrity sha512-5t8CBhlqQEcjJqNld1/ajcdZjjyrv7vsn4u0N3mX4hc4DnPJimIjYVFvP8nLyhSkioawWDIRLvzlmfzFs02lDg==
 
-"@lezer/common@^1.0.0", "@lezer/common@^1.0.2":
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.0.2.tgz#8fb9b86bdaa2ece57e7d59e5ffbcb37d71815087"
-  integrity sha512-SVgiGtMnMnW3ActR8SXgsDhw7a0w0ChHSYAyAUxxrOiJ1OqYWEKk/xJd84tTSPo1mo6DXLObAJALNnd0Hrv7Ng==
+"@jridgewell/resolve-uri@^3.1.0":
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz#c08679063f279615a3326583ba3a90d1d82cc721"
+  integrity sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==
+
+"@jridgewell/sourcemap-codec@^1.4.14":
+  version "1.4.15"
+  resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32"
+  integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==
+
+"@jridgewell/trace-mapping@^0.3.12":
+  version "0.3.19"
+  resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz#f8a3249862f91be48d3127c3cfe992f79b4b8811"
+  integrity sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==
+  dependencies:
+    "@jridgewell/resolve-uri" "^3.1.0"
+    "@jridgewell/sourcemap-codec" "^1.4.14"
+
+"@lezer/common@^1.0.0", "@lezer/common@^1.0.2", "@lezer/common@^1.1.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.1.0.tgz#2e5bfe01d7a2ada6056d93c677bba4f1495e098a"
+  integrity sha512-XPIN3cYDXsoJI/oDWoR2tD++juVrhgIago9xyKhZ7IhGlzdDM9QgC8D8saKNCz5pindGcznFr2HBSsEQSWnSjw==
 
 "@lezer/cpp@^1.0.0":
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/@lezer/cpp/-/cpp-1.1.0.tgz#5aaecac684437925d650252d7e9d97acf8f8f095"
-  integrity sha512-zUHrjNFuY/DOZCkOBJ6qItQIkcopHM/Zv/QOE0a4XNG3HDNahxTNu5fQYl8dIuKCpxCqRdMl5cEwl5zekFc7BA==
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/@lezer/cpp/-/cpp-1.1.1.tgz#ac0261f48dc3651bfea13fdaeff35f04c9011a7f"
+  integrity sha512-eS1M3L3U2mDowoFVPG7tEp01SWu9/68Nx3HEBgLJVn3N9ku7g5S7WdFv0jzmcTipAyONYfZJ+7x4WRkfdB2Ung==
   dependencies:
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
 "@lezer/css@^1.0.0", "@lezer/css@^1.1.0":
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/@lezer/css/-/css-1.1.1.tgz#c36dcb0789317cb80c3740767dd3b85e071ad082"
-  integrity sha512-mSjx+unLLapEqdOYDejnGBokB5+AiJKZVclmud0MKQOKx3DLJ5b5VTCstgDDknR6iIV4gVrN6euzsCnj0A2gQA==
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/@lezer/css/-/css-1.1.3.tgz#605495b00fd8a122088becf196a93744cbe817fc"
+  integrity sha512-SjSM4pkQnQdJDVc80LYzEaMiNy9txsFbI7HsMgeVF28NdLaAdHNtQ+kB/QqDUzRBV/75NTXjJ/R5IdC8QQGxMg==
   dependencies:
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
 "@lezer/highlight@^1.0.0", "@lezer/highlight@^1.1.3":
-  version "1.1.4"
-  resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.1.4.tgz#98ed821e89f72981b7ba590474e6ee86c8185619"
-  integrity sha512-IECkFmw2l7sFcYXrV8iT9GeY4W0fU4CxX0WMwhmhMIVjoDdD1Hr6q3G2NqVtLg/yVe5n7i4menG3tJ2r4eCrPQ==
+  version "1.1.6"
+  resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.1.6.tgz#87e56468c0f43c2a8b3dc7f0b7c2804b34901556"
+  integrity sha512-cmSJYa2us+r3SePpRCjN5ymCqCPv+zyXmDl0ciWtVaNiORT/MxM7ZgOMQZADD0o51qOaOg24qc/zBViOIwAjJg==
   dependencies:
     "@lezer/common" "^1.0.0"
 
 "@lezer/html@^1.3.0":
-  version "1.3.4"
-  resolved "https://registry.yarnpkg.com/@lezer/html/-/html-1.3.4.tgz#7a5c5498dae6c93aee3de208bfb01aa3a0a932e3"
-  integrity sha512-HdJYMVZcT4YsMo7lW3ipL4NoyS2T67kMPuSVS5TgLGqmaCjEU/D6xv7zsa1ktvTK5lwk7zzF1e3eU6gBZIPm5g==
+  version "1.3.6"
+  resolved "https://registry.yarnpkg.com/@lezer/html/-/html-1.3.6.tgz#26a2a17da4e0f91835e36db9ccd025b2ed8d33f7"
+  integrity sha512-Kk9HJARZTc0bAnMQUqbtuhFVsB4AnteR2BFUWfZV7L/x1H0aAKz6YabrfJ2gk/BEgjh9L3hg5O4y2IDZRBdzuQ==
   dependencies:
     "@lezer/common" "^1.0.0"
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
 "@lezer/java@^1.0.0":
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/@lezer/java/-/java-1.0.3.tgz#393e333fcdb64f7308e0ce120005b0065668e1d2"
-  integrity sha512-kKN17wmgP1cgHb8juR4pwVSPMKkDMzY/lAPbBsZ1fpXwbk2sg3N1kIrf0q+LefxgrANaQb/eNO7+m2QPruTFng==
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/@lezer/java/-/java-1.0.4.tgz#f31f5af4bfc40475dc886f0e3e2d291889b87d25"
+  integrity sha512-POc53LHf2AuNeRXjqZbXNu88GKj0KZTjjSx0L7tYeXlrEHF+3NAQx+dEwKVuCbkl0ZMtpRy2VsDYOV7KKV0oyg==
   dependencies:
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
 "@lezer/javascript@^1.0.0":
-  version "1.4.3"
-  resolved "https://registry.yarnpkg.com/@lezer/javascript/-/javascript-1.4.3.tgz#f59e764a0578184c6fb86abb5279a9679777c3ba"
-  integrity sha512-k7Eo9z9B1supZ5cCD4ilQv/RZVN30eUQL+gGbr6ybrEY3avBAL5MDiYi2aa23Aj0A79ry4rJRvPAwE2TM8bd+A==
+  version "1.4.7"
+  resolved "https://registry.yarnpkg.com/@lezer/javascript/-/javascript-1.4.7.tgz#4ebcce2db6043c07fbe827188c07cb001bc7fe37"
+  integrity sha512-OVWlK0YEi7HM+9JRWtRkir8qvcg0/kVYg2TAMHlVtl6DU1C9yK1waEOLBMztZsV/axRJxsqfJKhzYz+bxZme5g==
   dependencies:
     "@lezer/highlight" "^1.1.3"
     "@lezer/lr" "^1.3.0"
 
 "@lezer/json@^1.0.0":
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/@lezer/json/-/json-1.0.0.tgz#848ad9c2c3e812518eb02897edd5a7f649e9c160"
-  integrity sha512-zbAuUY09RBzCoCA3lJ1+ypKw5WSNvLqGMtasdW6HvVOqZoCpPr8eWrsGnOVWGKGn8Rh21FnrKRVlJXrGAVUqRw==
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@lezer/json/-/json-1.0.1.tgz#3bf5641f3d1408ec31a5f9b29e4e96c6e3a232e6"
+  integrity sha512-nkVC27qiEZEjySbi6gQRuMwa2sDu2PtfjSgz0A4QF81QyRGm3kb2YRzLcOPcTEtmcwvrX/cej7mlhbwViA4WJw==
   dependencies:
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
-"@lezer/lr@^1.0.0", "@lezer/lr@^1.1.0", "@lezer/lr@^1.3.0", "@lezer/lr@^1.3.1":
-  version "1.3.4"
-  resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.3.4.tgz#8795bf2ba4f69b998e8fb4b5a7c57ea68753474c"
-  integrity sha512-7o+e4og/QoC/6btozDPJqnzBhUaD1fMfmvnEKQO1wRRiTse1WxaJ3OMEXZJnkgT6HCcTVOctSoXK9jGJw2oe9g==
+"@lezer/lr@^1.0.0", "@lezer/lr@^1.1.0", "@lezer/lr@^1.3.0", "@lezer/lr@^1.3.1", "@lezer/lr@^1.3.3":
+  version "1.3.12"
+  resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.3.12.tgz#ee65d79f5528d8f5c042cd8123325a48c411109b"
+  integrity sha512-5nwY1JzCueUdRtlMBnlf1SUi69iGCq2ABq7WQFQMkn/kxPvoACAEnTp4P17CtXxYr7WCwtYPLL2AEvxKPuF1OQ==
   dependencies:
     "@lezer/common" "^1.0.0"
 
 "@lezer/markdown@^1.0.0":
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/@lezer/markdown/-/markdown-1.0.2.tgz#8c804a9f6fe1ccca4a20acd2fd9fbe0fae1ae178"
-  integrity sha512-8CY0OoZ6V5EzPjSPeJ4KLVbtXdLBd8V6sRCooN5kHnO28ytreEGTyrtU/zUwo/XLRzGr/e1g44KlzKi3yWGB5A==
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@lezer/markdown/-/markdown-1.1.0.tgz#5cee104ef353a3442ecee023ff1912826fac8658"
+  integrity sha512-JYOI6Lkqbl83semCANkO3CKbKc0pONwinyagBufWBm+k4yhIcqfCF8B8fpEpvJLmIy7CAfwiq7dQ/PzUZA340g==
   dependencies:
     "@lezer/common" "^1.0.0"
     "@lezer/highlight" "^1.0.0"
@@ -397,50 +532,62 @@
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.1.0"
 
-"@lezer/python@^1.0.0":
-  version "1.1.4"
-  resolved "https://registry.yarnpkg.com/@lezer/python/-/python-1.1.4.tgz#6ef58ff965286150fea9f2db776944a1d69cd9b9"
-  integrity sha512-x82XgYxqqX0Yiw7uIemQJ3z2QyQme5BYpectkPfNg99OQrakqfwqVolqEVIrsj4QO9rVDLFZZ49J0Vbne7UbAA==
+"@lezer/python@^1.1.4":
+  version "1.1.8"
+  resolved "https://registry.yarnpkg.com/@lezer/python/-/python-1.1.8.tgz#fe8d03d6cbc95a1d5625cffd30d78018ee816633"
+  integrity sha512-1T/XsmeF57ijrjpC0Zmrf9YeO5mn2zC1XeSNrOnc0KB+6PgxJ5m7kWKt0CnwyS74oHQXbJxUUL+QDQJR26c1Gw==
   dependencies:
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
 "@lezer/rust@^1.0.0":
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/@lezer/rust/-/rust-1.0.0.tgz#939f3e7b0376ebe13f4ac336ed7d59ca2c8adf52"
-  integrity sha512-IpGAxIjNxYmX9ra6GfQTSPegdCAWNeq23WNmrsMMQI7YNSvKtYxO4TX5rgZUmbhEucWn0KTBMeDEPXg99YKtTA==
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@lezer/rust/-/rust-1.0.1.tgz#ac2d7263fe22527e621bb5623929ba6d6c3a29ea"
+  integrity sha512-j+ToFKM6Wpglv3OQ4ebHYdYIMT2dh0ziCCV0rTf47AWiHOVhR0WjaKrBq+yuvDQNEhr5sxPxVI7+naJIgpqcsQ==
   dependencies:
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
 "@lezer/sass@^1.0.0":
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/@lezer/sass/-/sass-1.0.1.tgz#c0ec3ece28b04e92437a75ac4a806367e5cb6fd4"
-  integrity sha512-S/aYAzABzMqWLfKKqV89pCWME4yjZYC6xzD02l44wbmb0sHxmN9/8aE4GULrKFzFaGazHdXcGEbPZ4zzB6yqwQ==
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/@lezer/sass/-/sass-1.0.3.tgz#17e5d27e40979bc8b4aec8d05df0d01f745aedb8"
+  integrity sha512-n4l2nVOB7gWiGU/Cg2IVxpt2Ic9Hgfgy/7gk+p/XJibAsPXs0lSbsfGwQgwsAw9B/euYo3oS6lEFr9WytoqcZg==
   dependencies:
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
 "@lezer/xml@^1.0.0":
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/@lezer/xml/-/xml-1.0.1.tgz#c4c738a407db610f0e9c59d0e9b16607cd029591"
-  integrity sha512-jMDXrV953sDAUEMI25VNrI9dz94Ai96FfeglytFINhhwQ867HKlCE2jt3AwZTCT7M528WxdDWv/Ty8e9wizwmQ==
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@lezer/xml/-/xml-1.0.2.tgz#5c934602d1d3565fdaf04e93b534c8b94f4df2d1"
+  integrity sha512-dlngsWceOtQBMuBPw5wtHpaxdPJ71aVntqjbpGkFtWsp4WtQmCnuTjQGocviymydN6M18fhj6UQX3oiEtSuY7w==
   dependencies:
     "@lezer/highlight" "^1.0.0"
     "@lezer/lr" "^1.0.0"
 
-"@lit-labs/ssr-dom-shim@^1.0.0":
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.0.0.tgz#427e19a2765681fd83411cd72c55ba80a01e0523"
-  integrity sha512-ic93MBXfApIFTrup4a70M/+ddD8xdt2zxxj9sRwHQzhS9ag/syqkD8JPdTXsc1gUy2K8TTirhlCqyTEM/sifNw==
+"@lit-labs/ssr-dom-shim@^1.0.0", "@lit-labs/ssr-dom-shim@^1.1.0":
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.1.tgz#64df34e2f12e68e78ac57e571d25ec07fa460ca9"
+  integrity sha512-kXOeFbfCm4fFf2A3WwVEeQj55tMZa8c8/f9AKHMobQMkzNUfUj+antR3fRPaZJawsa1aZiP/Da3ndpZrwEe4rQ==
+
+"@lit-labs/ssr-dom-shim@^1.1.2-pre.0":
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.2.tgz#d693d972974a354034454ec1317eb6afd0b00312"
+  integrity sha512-jnOD+/+dSrfTWYfSXBXlo5l5f0q1UuJo3tkbMDCYA2lKUYq79jaxqtGEvnRoh049nt1vdo1+45RinipU6FGY2g==
 
 "@lit/reactive-element@^1.0.0", "@lit/reactive-element@^1.3.0", "@lit/reactive-element@^1.6.0":
-  version "1.6.1"
-  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.6.1.tgz#0d958b6d479d0e3db5fc1132ecc4fa84be3f0b93"
-  integrity sha512-va15kYZr7KZNNPZdxONGQzpUr+4sxVu7V/VG7a8mRfPPXUyhEYj5RzXCQmGrlP3tAh0L3HHm5AjBMFYRqlM9SA==
+  version "1.6.3"
+  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.6.3.tgz#25b4eece2592132845d303e091bad9b04cdcfe03"
+  integrity sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==
   dependencies:
     "@lit-labs/ssr-dom-shim" "^1.0.0"
 
+"@lit/reactive-element@^2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-2.0.0.tgz#da14a256ac5533873b935840f306d572bac4a2ab"
+  integrity sha512-wn+2+uDcs62ROBmVAwssO4x5xue/uKD3MGGZOXL2sMxReTRIT0JXKyMXeu7gh0aJ4IJNEIG/3aOnUaQvM7BMzQ==
+  dependencies:
+    "@lit-labs/ssr-dom-shim" "^1.1.2-pre.0"
+
 "@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"
@@ -475,54 +622,54 @@
     "@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/dedupe-mixin@^1.4.0":
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/@open-wc/dedupe-mixin/-/dedupe-mixin-1.4.0.tgz#b3c58f8699b197bb5e923d624c720e67c9f324d6"
+  integrity sha512-Sj7gKl1TLcDbF7B6KUhtvr+1UCxdhMbNY5KxdU5IfMFWqL8oy1ZeAcCANjoB1TL0AJTcPmcCFsCbHf8X2jGDUA==
 
-"@open-wc/scoped-elements@^2.1.3":
-  version "2.1.4"
-  resolved "https://registry.yarnpkg.com/@open-wc/scoped-elements/-/scoped-elements-2.1.4.tgz#8064abaa69bc2fb67695115c077aabedc9333b68"
-  integrity sha512-KX/bOkcDG9kbBDSmgsbpp40ZjEWxpWNrNRZZVSO0KqBygMfvfiEeVfP16uJp9YyWHi/PVZ/C0aUEgf8Pg1Eq7A==
+"@open-wc/scoped-elements@^2.2.0":
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/@open-wc/scoped-elements/-/scoped-elements-2.2.0.tgz#4d65d7ba796c2bb76ef7934068532ca1795ea7b6"
+  integrity sha512-Qe+vWsuVHFzUkdChwlmJGuQf9cA3I+QOsSHULV/6qf6wsqLM2/32svNRH+rbBIMwiPEwzZprZlkvkqQRucYnVA==
   dependencies:
     "@lit/reactive-element" "^1.0.0"
-    "@open-wc/dedupe-mixin" "^1.3.0"
+    "@open-wc/dedupe-mixin" "^1.4.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==
+"@open-wc/semantic-dom-diff@^0.20.0":
+  version "0.20.0"
+  resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.20.0.tgz#3766aa88f67df624db0494adf82c8035216a2493"
+  integrity sha512-qGHl3nkXluXsjpLY9bSZka/cnlrybPtJMs6RjmV/OP4ID7Gcz1uNWQks05pAhptDB1R47G6PQjdwxG8dXl1zGA==
   dependencies:
     "@types/chai" "^4.3.1"
-    "@web/test-runner-commands" "^0.6.1"
+    "@web/test-runner-commands" "^0.7.0"
 
-"@open-wc/testing-helpers@^2.1.4":
-  version "2.1.4"
-  resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-2.1.4.tgz#4b439442ecb1ea3fbcbb1ef76e8717574d78dc97"
-  integrity sha512-iZJxxKI9jRgnPczm8p2jpuvBZ3DHYSLrBmhDfzs7ol8vXMNt+HluzM1j1TSU95MFVGnfaspvvt9fMbXKA7cNcA==
+"@open-wc/testing-helpers@^2.3.0":
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-2.3.0.tgz#6ee88baaf316a6217c43e7ba536cb187d15cb6f4"
+  integrity sha512-wkDipkia/OMWq5Z1KkAgvqNLfIOCiPGrrtfoCKuQje8u7F0Bz9Un44EwBtWcCdYtLc40quWP7XFpFsW8poIfUA==
   dependencies:
-    "@open-wc/scoped-elements" "^2.1.3"
+    "@open-wc/scoped-elements" "^2.2.0"
     lit "^2.0.0"
     lit-html "^2.0.0"
 
-"@open-wc/testing@^3.1.6":
-  version "3.1.7"
-  resolved "https://registry.yarnpkg.com/@open-wc/testing/-/testing-3.1.7.tgz#65200c759626d510fda103c3cb4ede6202b1b88b"
-  integrity sha512-HCS2LuY6hXtEwjqmad+eanId5H7E+3mUi9Z3rjAhH+1DCJ53lUnjzWF1lbCYbREqrdCpmzZvW1t5R3e9gJZSCA==
+"@open-wc/testing@^3.2.0":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing/-/testing-3.2.0.tgz#884ca348861a116829ce5657fccff11a1a9a07bd"
+  integrity sha512-9geTbFq8InbcfniPtS8KCfb5sbQ9WE6QMo1Tli8XMnfllnkZok7Az4kTRAskGQeMeQN/I2I//jE5xY/60qhrHg==
   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.4"
+    "@open-wc/semantic-dom-diff" "^0.20.0"
+    "@open-wc/testing-helpers" "^2.3.0"
     "@types/chai" "^4.2.11"
-    "@types/chai-dom" "^0.0.12"
+    "@types/chai-dom" "^1.11.0"
     "@types/sinon-chai" "^3.2.3"
-    chai-a11y-axe "^1.3.2"
+    chai-a11y-axe "^1.5.0"
 
 "@polymer/decorators@^3.0.0":
   version "3.0.0"
@@ -531,13 +678,27 @@
   dependencies:
     "@polymer/polymer" "^3.0.5"
 
-"@polymer/polymer@^3.0.5", "@polymer/polymer@^3.4.1":
+"@polymer/polymer@^3.0.5", "@polymer/polymer@^3.5.1":
   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"
 
+"@puppeteer/browsers@0.5.0":
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-0.5.0.tgz#1a1ee454b84a986b937ca2d93146f25a3fe8b670"
+  integrity sha512-Uw6oB7VvmPRLE4iKsjuOh8zgDabhNX67dzo8U/BB0f9527qx+4eeUs+korU98OhG5C4ubg7ufBgVi63XYwS6TQ==
+  dependencies:
+    debug "4.3.4"
+    extract-zip "2.0.1"
+    https-proxy-agent "5.0.1"
+    progress "2.0.3"
+    proxy-from-env "1.1.0"
+    tar-fs "2.1.1"
+    unbzip2-stream "1.4.3"
+    yargs "17.7.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"
@@ -573,12 +734,19 @@
   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==
+"@sinonjs/commons@^3.0.0":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.0.tgz#beb434fe875d965265e04722ccfc21df7f755d72"
+  integrity sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==
   dependencies:
-    "@sinonjs/commons" "^2.0.0"
+    type-detect "4.0.8"
+
+"@sinonjs/fake-timers@^10.0.2":
+  version "10.3.0"
+  resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz#55fdff1ecab9f354019129daf4df0dd4d923ea66"
+  integrity sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==
+  dependencies:
+    "@sinonjs/commons" "^3.0.0"
 
 "@sinonjs/fake-timers@^9.1.2":
   version "9.1.2"
@@ -609,64 +777,64 @@
     "@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==
+  version "7.0.4"
+  resolved "https://registry.yarnpkg.com/@types/babel__code-frame/-/babel__code-frame-7.0.4.tgz#0d14543f70ca91f4d2b0513a60f1eb31432c42e1"
+  integrity sha512-WBxINLlATjvmpCgBbb9tOPrKtcPfu4A/Yz2iRzmdaodfvjAS/Z0WZJClV9/EXvoC9viI3lgUs7B9Uo7G/RmMGg==
 
 "@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==
+  version "1.19.3"
+  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.3.tgz#fb558014374f7d9e56c8f34bab2042a3a07d25cd"
+  integrity sha512-oyl4jvAfTGX9Bt6Or4H9ni1Z447/tQuxnZsytsCaExKlmJiU8sFgnIBRzJUpKwB5eWn9HuBYlUlVA74q/yN0eQ==
   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==
+"@types/chai-dom@^1.11.0":
+  version "1.11.1"
+  resolved "https://registry.yarnpkg.com/@types/chai-dom/-/chai-dom-1.11.1.tgz#5f91fb34a612ccef177c70100c7c1b98a684d696"
+  integrity sha512-q+fs4jdKZFDhXOWBehY0jDGCp8nxVe11Ia8MxqlIsJC3Y2JU149PSBYF2li2F3uxJFSAl2Rf8XeLWonHglpcGw==
   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.4"
-  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.4.tgz#e913e8175db8307d78b4e8fa690408ba6b65dee4"
-  integrity sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw==
+  version "4.3.6"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.6.tgz#7b489e8baf393d5dd1266fb203ddd4ea941259e6"
+  integrity sha512-VOVRLM1mBxIRxydiViqPcKn6MIxZytrbMpd6RJLIWKxUNr3zux8no0Oc7kJx0WAPIitgZ0gkrDS+btlqQpubpw==
 
 "@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==
+  version "6.1.1"
+  resolved "https://registry.yarnpkg.com/@types/co-body/-/co-body-6.1.1.tgz#28d253c95cfbe30c8e8c5d69d4c0dbbcffc101c2"
+  integrity sha512-I9A1k7o4m8m6YPYJIGb1JyNTLqRWtSPg1JOZPWlE19w8Su2VRgRVp/SkKftQSwoxWHGUxGbON4jltONMumC8bQ==
   dependencies:
     "@types/node" "*"
     "@types/qs" "*"
 
 "@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==
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/@types/command-line-args/-/command-line-args-5.2.1.tgz#233bd1ba687e84ecbec0388e09f9ec9ebf63c55b"
+  integrity sha512-U2OcmS2tj36Yceu+mRuPyUV0ILfau/h5onStcSCzqTENsq0sBiAp2TmaXu1k8fY4McLcPKSYl9FRzn2hx5bI+w==
 
 "@types/connect@*":
-  version "3.4.35"
-  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1"
-  integrity sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==
+  version "3.4.36"
+  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.36.tgz#e511558c15a39cb29bd5357eebb57bd1459cd1ab"
+  integrity sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==
   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==
+  version "0.5.6"
+  resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.6.tgz#0f5fa03609f308a7a1a57e0b0afe4b95f1d19740"
+  integrity sha512-GmShTb4qA9+HMPPaV2+Up8tJafgi38geFi7vL4qAM7k8BwjoelgHZqEUKJZLvughUw22h6vD/wvwN4IUCaWpDA==
 
-"@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/convert-source-map@^2.0.0":
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/@types/convert-source-map/-/convert-source-map-2.0.1.tgz#e72e8a3de9d6fe3d8e43d5c101c346de2ff6abdf"
+  integrity sha512-tm5Eb3AwhibN6ULRaad5TbNO83WoXVZLh2YRGAFH+qWkUz48l9Hu1jc+wJswB7T+ACWAG0cFnTeeQGpwedvlNw==
 
 "@types/cookies@*":
-  version "0.7.7"
-  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.7.tgz#7a92453d1d16389c05a5301eef566f34946cfd81"
-  integrity sha512-h7BcvPUogWbKCzBR2lY4oqaZbO3jXZksexYJVFvkrFeLgbZjQkU4x8pRq6eg2MHXQhY0McQdqmmsxRWlVAHooA==
+  version "0.7.8"
+  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.8.tgz#16fccd6d58513a9833c527701a90cc96d216bc18"
+  integrity sha512-y6KhF1GtsLERUpqOV+qZJrjUGzc0GE6UTa0b5Z/LZ7Nm2mKSdCXmS6Kdnl7fctPNnMSouHjxqEWI12/YqQfk5w==
   dependencies:
     "@types/connect" "*"
     "@types/express" "*"
@@ -674,9 +842,9 @@
     "@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==
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.2.tgz#8a9fd94003d874b56204526e6686b8a57dc4b278"
+  integrity sha512-ow0L7we5RXNQocEO9LNBRJCk/ecBc8M0aTg0DLrlg1nsnKAcjvFmYFUbsxujlrbngRslmKIA4mKoOxIJdUElhw==
 
 "@types/estree@0.0.39":
   version "0.0.39"
@@ -684,18 +852,19 @@
   integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
 
 "@types/express-serve-static-core@^4.17.33":
-  version "4.17.33"
-  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.33.tgz#de35d30a9d637dc1450ad18dd583d75d5733d543"
-  integrity sha512-TPBqmR/HRYI3eC2E5hmiivIzv+bidAfXofM+sbonAGvyDhySGw9/PQZFt2BLOrjUUR++4eJVpx6KnLQK1Fk9tA==
+  version "4.17.37"
+  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.37.tgz#7e4b7b59da9142138a2aaa7621f5abedce8c7320"
+  integrity sha512-ZohaCYTgGFcOP7u6aJOhY9uIZQgZ2vxC2yWoArY+FeDXlqeH66ZVBjgvg+RLVAS/DWNq4Ap9ZXu1+SUQiiWYMg==
   dependencies:
     "@types/node" "*"
     "@types/qs" "*"
     "@types/range-parser" "*"
+    "@types/send" "*"
 
 "@types/express@*":
-  version "4.17.17"
-  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.17.tgz#01d5437f6ef9cfa8668e616e13c2f2ac9a491ae4"
-  integrity sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==
+  version "4.17.18"
+  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.18.tgz#efabf5c4495c1880df1bdffee604b143b29c4a95"
+  integrity sha512-Sxv8BSLLgsBYmcnGdGjjEjqET2U+AKAdCRODmMiq02FgjwuV75Ut85DRpvFjyw/Mk0vgUOliGRU0UUmuuZHByQ==
   dependencies:
     "@types/body-parser" "*"
     "@types/express-serve-static-core" "^4.17.33"
@@ -708,9 +877,9 @@
   integrity sha512-FyAOrDuQmBi8/or3ns4rwPno7/9tJTijVW6aQQjK02+kOQ8zmoNg2XJtAuQhvQcy1ASJq38wirX5//9J1EqoUA==
 
 "@types/http-errors@*":
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.1.tgz#20172f9578b225f6c7da63446f56d4ce108d5a65"
-  integrity sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.2.tgz#a86e00bbde8950364f8e7846687259ffcd96e8c2"
+  integrity sha512-lPG6KlZs88gef6aD85z3HNkztpj7w2R7HmR3gygjfXCQmsLloWNARFkMuzKiiY8FGdh1XDpgBdrSf4aKDiA7Kg==
 
 "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.3":
   version "2.0.4"
@@ -732,21 +901,21 @@
     "@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==
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.3.tgz#2286b16ef71d8dea74dab00902ef419a54341bfe"
+  integrity sha512-tfzBBb7OV2PbUfKbG6zRE5UbmtdLVCKT/XT364Z9ny6pXNbd9GnIB6aFYpq2A5lZ6mq9bhXgK6h5MFGNwhMmuQ==
 
 "@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==
+  version "3.2.6"
+  resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.6.tgz#17a077786d0ac5eee04c37a7d6c207b3252f6de9"
+  integrity sha512-PHiciWxH3NRyAaxUdEDE1NIZNfvhgtPlsdkjRPazHC6weqt90Jr0uLhIQs+SDwC8HQ/jnA7UQP6xOqGFB7ugWw==
   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==
+  version "2.13.9"
+  resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.9.tgz#8d989ac17d7f033475fbe34c4f906c9287c2041a"
+  integrity sha512-tPX3cN1dGrMn+sjCDEiQqXH2AqlPoPd594S/8zxwUm/ZbPsQXKqHPUypr2gjCPhHUc+nDJLduhh5lXI/1olnGQ==
   dependencies:
     "@types/accepts" "*"
     "@types/content-disposition" "*"
@@ -762,15 +931,20 @@
   resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10"
   integrity sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==
 
+"@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/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.14.2"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-18.14.2.tgz#c076ed1d7b6095078ad3cf21dfeea951842778b1"
-  integrity sha512-1uEQxww3DaghA0RxqHx0O0ppVlo43pJhepY51OxuQIKHpjbnYLA7vcdwioNPzIqmC2u3I/dmylcqjlh0e7AyUA==
+  version "20.6.5"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-20.6.5.tgz#4c6a79adf59a8e8193ac87a0e522605b16587258"
+  integrity sha512-2qGq5LAOTh9izcc0+F+dToFigBWiK1phKPt7rNhOqJSr35y8rlIBjDwGtFSgAI6MGIhjwOVNSQZVdJsZJ2uR1w==
 
 "@types/parse5@^6.0.1":
   version "6.0.3"
@@ -778,9 +952,9 @@
   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==
+  version "6.9.8"
+  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.8.tgz#f2a7de3c107b89b441e071d5472e6b726b4adf45"
+  integrity sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==
 
 "@types/range-parser@*":
   version "1.2.4"
@@ -794,11 +968,20 @@
   dependencies:
     "@types/node" "*"
 
-"@types/serve-static@*":
-  version "1.15.1"
-  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.1.tgz#86b1753f0be4f9a1bee68d459fcda5be4ea52b5d"
-  integrity sha512-NUo5XNiAdULrJENtJXZZ3fHtfMolzZwczzBbnAeBbqBwG+LaG6YaJtuwzwGSQZ2wsCrxjEhNNjAkKigy3n8teQ==
+"@types/send@*":
+  version "0.17.1"
+  resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.1.tgz#ed4932b8a2a805f1fe362a70f4e62d0ac994e301"
+  integrity sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==
   dependencies:
+    "@types/mime" "^1"
+    "@types/node" "*"
+
+"@types/serve-static@*":
+  version "1.15.2"
+  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.2.tgz#3e5419ecd1e40e7405d34093f10befb43f63381a"
+  integrity sha512-J2LqtvFYCzaj8pVYKw8klQXrLLk7TBZmQ4ShlcdkELFKGwGMfevMLneMMRkMgZxotOD9wg497LpC7O8PcvAmfw==
+  dependencies:
+    "@types/http-errors" "*"
     "@types/mime" "*"
     "@types/node" "*"
 
@@ -811,9 +994,9 @@
     "@types/sinon" "*"
 
 "@types/sinon@*":
-  version "10.0.13"
-  resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.13.tgz#60a7a87a70d9372d0b7b38cc03e825f46981fb83"
-  integrity sha512-UVjDqJblVNQYvVNUsj0PuYYw0ELRmgt1Nt5Vk0pT5f16ROGfcKJY8o1HVuMOJOpD727RrGB9EGvoaTQE5tgxZQ==
+  version "10.0.16"
+  resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.16.tgz#4bf10313bd9aa8eef1e50ec9f4decd3dd455b4d3"
+  integrity sha512-j2Du5SYpXZjJVJtXBokASpPRj+e2z+VUhCPHmM6WMfe3dpHu6iVKJMU6AiBcMp/XTAYnEj6Wc1trJUWwZ0QaAQ==
   dependencies:
     "@types/sinonjs__fake-timers" "*"
 
@@ -823,9 +1006,9 @@
   integrity sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA==
 
 "@types/trusted-types@^2.0.2":
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.3.tgz#a136f83b0758698df454e328759dbd3d44555311"
-  integrity sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.4.tgz#2b38784cd16957d3782e8e2b31c03bc1d13b4d65"
+  integrity sha512-IDaobHimLQhjwsQ/NMwRVfa/yL7L/wriQPMhw1ZJall0KX6E1oxk29XMDeilW5qTIg5aoiqf5Udy8U/51aNoQQ==
 
 "@types/ws@^7.4.0":
   version "7.4.7"
@@ -835,16 +1018,23 @@
     "@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==
+  version "2.10.1"
+  resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.1.tgz#4e8f299f0934d60f36c74f59cb5a8483fd786691"
+  integrity sha512-CHzgNU3qYBnp/O4S3yv2tXPlvMTq0YWSTVg2/JYLqWZGHwwgJGAwd00poay/11asPq8wLFwHzubyInqHIFmmiw==
   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==
+"@web/browser-logs@^0.2.6":
+  version "0.2.6"
+  resolved "https://registry.yarnpkg.com/@web/browser-logs/-/browser-logs-0.2.6.tgz#ec936f78c7cf7b0ef9fb990c0097a3da1a756b20"
+  integrity sha512-CNjNVhd4FplRY8PPWIAt02vAowJAVcOoTNrR/NNb/o9pka7yI9qdjpWrWhEbPr2pOXonWb52AeAgdK66B8ZH7w==
+  dependencies:
+    errorstacks "^2.2.0"
+
+"@web/browser-logs@^0.3.2":
+  version "0.3.3"
+  resolved "https://registry.yarnpkg.com/@web/browser-logs/-/browser-logs-0.3.3.tgz#121e5b662db2707c4b8cd1628d86903f059f5031"
+  integrity sha512-wt8arj0x7ghXbnipgCvLR+xQ90cFg16ae23cFbInCrJvAxvyI22bAtT24W4XOXMPXwWLBVUJwBgBcXo3oKIvDw==
   dependencies:
     errorstacks "^2.2.0"
 
@@ -855,20 +1045,20 @@
   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==
+"@web/dev-server-core@^0.4.1":
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-core/-/dev-server-core-0.4.1.tgz#803faff45281ee296d0dda02dfdd905c330db4d8"
+  integrity sha512-KdYwejXZwIZvb6tYMCqU7yBiEOPfKLQ3V9ezqqEz8DA9V9R3oQWaowckvCpFB9IxxPfS/P8/59OkdzGKQjcIUw==
   dependencies:
     "@types/koa" "^2.11.6"
     "@types/ws" "^7.4.0"
-    "@web/parse5-utils" "^1.2.0"
+    "@web/parse5-utils" "^1.3.1"
     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"
+    isbinaryfile "^5.0.0"
     koa "^2.13.0"
     koa-etag "^4.0.0"
     koa-send "^5.0.1"
@@ -879,42 +1069,66 @@
     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==
+"@web/dev-server-core@^0.5.1":
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-core/-/dev-server-core-0.5.2.tgz#27fe5448e587a87272b556b44ce84c6453655cdb"
+  integrity sha512-7YjWmwzM+K5fPvBCXldUIMTK4EnEufi1aWQWinQE81oW1CqzEwmyUNCtnWV9fcPA4kJC4qrpcjWNGF4YDWxuSg==
+  dependencies:
+    "@types/koa" "^2.11.6"
+    "@types/ws" "^7.4.0"
+    "@web/parse5-utils" "^2.0.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 "^5.0.0"
+    koa "^2.13.0"
+    koa-etag "^4.0.0"
+    koa-send "^5.0.1"
+    koa-static "^5.0.0"
+    lru-cache "^8.0.4"
+    mime-types "^2.1.27"
+    parse5 "^6.0.1"
+    picomatch "^2.2.2"
+    ws "^7.4.2"
+
+"@web/dev-server-esbuild@^0.3.6":
+  version "0.3.6"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-esbuild/-/dev-server-esbuild-0.3.6.tgz#838100894937443b96bfc4266c7795d27ed4afac"
+  integrity sha512-VDcZOzvmbg/z/8Q54hHqFwt9U4cacQJZxgS8YXAvyFuG85HAJ/Q55P7Tr++1NlRS8wQEos6QK2ERUWNjEVOhqQ==
   dependencies:
     "@mdn/browser-compat-data" "^4.0.0"
-    "@web/dev-server-core" "^0.3.19"
-    esbuild "^0.12 || ^0.13 || ^0.14"
+    "@web/dev-server-core" "^0.4.1"
+    esbuild "^0.16 || ^0.17"
     parse5 "^6.0.1"
-    ua-parser-js "^1.0.2"
+    ua-parser-js "^1.0.33"
 
-"@web/dev-server-rollup@^0.3.19":
-  version "0.3.21"
-  resolved "https://registry.yarnpkg.com/@web/dev-server-rollup/-/dev-server-rollup-0.3.21.tgz#edeecc599970fcc03f6a53fd7c5fdaf01178e88a"
-  integrity sha512-138t+vMFkegRip6Rtlz68Bo5rl984C9c2rLg3dWl9JEEJSQcWgA3iEwXYh4xTc52WjXnM3/LpboAjTYQOMyfrA==
+"@web/dev-server-rollup@^0.4.1":
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-rollup/-/dev-server-rollup-0.4.1.tgz#3c6606bac8e497498b5b47bf9e0c544c321b38ef"
+  integrity sha512-Ebsv7Ovd9MufeH3exvikBJ7GmrZA5OmHnOgaiHcwMJ2eQBJA5/I+/CbRjsLX97ICj/ZwZG//p2ITRz8W3UfSqg==
   dependencies:
     "@rollup/plugin-node-resolve" "^13.0.4"
-    "@web/dev-server-core" "^0.3.19"
+    "@web/dev-server-core" "^0.4.1"
     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==
+"@web/dev-server@^0.1.38":
+  version "0.1.38"
+  resolved "https://registry.yarnpkg.com/@web/dev-server/-/dev-server-0.1.38.tgz#d755092d66aeb923c546237a6c460439ea3ddd29"
+  integrity sha512-WUq7Zi8KeJ5/UZmmpZ+kzUpUlFlMP/rcreJKYg9Lxiz998KYl4G5Rv24akX0piTuqXG7r6h+zszg8V/hdzjCoA==
   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"
+    "@web/dev-server-core" "^0.4.1"
+    "@web/dev-server-rollup" "^0.4.1"
     camelcase "^6.2.0"
     command-line-args "^5.1.1"
-    command-line-usage "^6.1.1"
+    command-line-usage "^7.0.1"
     debounce "^1.2.0"
     deepmerge "^4.2.2"
     ip "^1.1.5"
@@ -922,50 +1136,66 @@
     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==
+"@web/parse5-utils@^1.3.1":
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/@web/parse5-utils/-/parse5-utils-1.3.1.tgz#6727be4d7875a9ecb96a5b3003bd271da763f8b4"
+  integrity sha512-haCgDchZrAOB9EhBJ5XqiIjBMsS/exsM5Ru7sCSyNkXVEJWskyyKuKMFk66BonnIGMPpDtqDrTUfYEis5Zi3XA==
   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==
+"@web/parse5-utils@^2.0.0":
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/@web/parse5-utils/-/parse5-utils-2.0.1.tgz#11b91417165a838954dcf228383cfd8e1bdaf914"
+  integrity sha512-FQI72BU5CXhpp7gLRskOQGGCcwvagLZnMnDwAfjrxo3pm1KOQzr8Vl+438IGpHV62xvjNdF1pjXwXcf7eekWGw==
   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"
+    "@types/parse5" "^6.0.1"
+    parse5 "^6.0.1"
 
-"@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==
+"@web/test-runner-chrome@^0.12.1":
+  version "0.12.1"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-chrome/-/test-runner-chrome-0.12.1.tgz#3f4d7b83565807d75a84907ffd91ae1bd2298a52"
+  integrity sha512-QxzinqYHelZQpMHAuc5TYyWVhtHUEGhL3m1p2U+mTTTWrZYX3D0s6Q0oL2+XYT1dsja5sd71h7yiBTb9ctkKOg==
   dependencies:
-    "@web/test-runner-core" "^0.10.27"
+    "@web/test-runner-core" "^0.10.29"
+    "@web/test-runner-coverage-v8" "^0.5.0"
+    chrome-launcher "^0.15.0"
+    puppeteer-core "^19.8.1"
+
+"@web/test-runner-commands@^0.6.6":
+  version "0.6.6"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-commands/-/test-runner-commands-0.6.6.tgz#e0e8c4ce6dcd91e5b18cf2212511ee6108e31070"
+  integrity sha512-2DcK/+7f8QTicQpGFq/TmvKHDK/6Zald6rn1zqRlmj3pcH8fX6KHNVMU60Za9QgAKdorMBPfd8dJwWba5otzdw==
+  dependencies:
+    "@web/test-runner-core" "^0.10.29"
     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==
+"@web/test-runner-commands@^0.7.0":
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-commands/-/test-runner-commands-0.7.0.tgz#c9693e4e8b05ef06a2102e03ac924bcbf7985312"
+  integrity sha512-3aXeGrkynOdJ5jgZu5ZslcWmWuPVY9/HNdWDUqPyNePG08PKmLV9Ij342ODDL6OVsxF5dvYn1312PhDqu5AQNw==
+  dependencies:
+    "@web/test-runner-core" "^0.11.0"
+    mkdirp "^1.0.4"
+
+"@web/test-runner-core@^0.10.20", "@web/test-runner-core@^0.10.29":
+  version "0.10.29"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-core/-/test-runner-core-0.10.29.tgz#d8d909c849151cf19013d6084f89a31e400557d5"
+  integrity sha512-0/ZALYaycEWswHhpyvl5yqo0uIfCmZe8q14nGPi1dMmNiqLcHjyFGnuIiLexI224AW74ljHcHllmDlXK9FUKGA==
   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/convert-source-map" "^2.0.0"
     "@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"
+    "@web/browser-logs" "^0.2.6"
+    "@web/dev-server-core" "^0.4.1"
     chokidar "^3.4.3"
     cli-cursor "^3.1.0"
     co-body "^6.1.0"
-    convert-source-map "^1.7.0"
+    convert-source-map "^2.0.0"
     debounce "^1.2.0"
     dependency-graph "^0.11.0"
     globby "^11.0.1"
@@ -980,15 +1210,47 @@
     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==
+"@web/test-runner-core@^0.11.0":
+  version "0.11.4"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-core/-/test-runner-core-0.11.4.tgz#590994c3fc69337e2c5411bf11c293dd061cc07a"
+  integrity sha512-E7BsKAP8FAAEsfj4viASjmuaYfOw4UlKP9IFqo4W20eVyd1nbUWU3Amq4Jksh7W/j811qh3VaFNjDfCwklQXMg==
+  dependencies:
+    "@babel/code-frame" "^7.12.11"
+    "@types/babel__code-frame" "^7.0.2"
+    "@types/co-body" "^6.1.0"
+    "@types/convert-source-map" "^2.0.0"
+    "@types/debounce" "^1.2.0"
+    "@types/istanbul-lib-coverage" "^2.0.3"
+    "@types/istanbul-reports" "^3.0.0"
+    "@web/browser-logs" "^0.3.2"
+    "@web/dev-server-core" "^0.5.1"
+    chokidar "^3.4.3"
+    cli-cursor "^3.1.0"
+    co-body "^6.1.0"
+    convert-source-map "^2.0.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.1"
+    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.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 "^8.0.0"
+    v8-to-istanbul "^9.0.1"
 
 "@web/test-runner-mocha@^0.7.5":
   version "0.7.5"
@@ -998,22 +1260,22 @@
     "@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==
+"@web/test-runner@^0.15.3":
+  version "0.15.3"
+  resolved "https://registry.yarnpkg.com/@web/test-runner/-/test-runner-0.15.3.tgz#8cf51293e5889c5db63c75fb7d422b5c820dcf01"
+  integrity sha512-unwBymuQpI8yc/129K9H0aIzLIIQFrr2/mhdcIWFeZjjw5X3TJh57p5NFOA76nhlBSjFHyu0U0FXw9uOzXUCuQ==
   dependencies:
-    "@web/browser-logs" "^0.2.2"
+    "@web/browser-logs" "^0.2.6"
     "@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/dev-server" "^0.1.38"
+    "@web/test-runner-chrome" "^0.12.1"
+    "@web/test-runner-commands" "^0.6.6"
+    "@web/test-runner-core" "^0.10.29"
     "@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"
+    command-line-usage "^7.0.1"
+    convert-source-map "^2.0.0"
     diff "^5.0.0"
     globby "^11.0.1"
     nanocolors "^0.2.1"
@@ -1021,9 +1283,9 @@
     source-map "^0.7.3"
 
 "@webcomponents/shadycss@^1.9.1":
-  version "1.11.1"
-  resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.11.1.tgz#add19d5e0db4a014e143d2278921347dcd8f0a55"
-  integrity sha512-qSok/oMynEgS99wFY5fKT6cR1y64i01RkHGYOspkh2JQsLSM8pjciER+gu3fqTx589y/7LoSuyB5G9Rh7dyXaQ==
+  version "1.11.2"
+  resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.11.2.tgz#7539b0ad29598aa2eafee8b341059e20ac9e1006"
+  integrity sha512-vRq+GniJAYSBmTRnhCYPAPq6THYqovJ/gzGThWbgEZUQaBccndGTi1hdiUP15HzEco0I6t4RCtXyX0rsSmwgPw==
 
 accepts@^1.3.5:
   version "1.3.8"
@@ -1059,7 +1321,7 @@
   dependencies:
     color-convert "^1.9.0"
 
-ansi-styles@^4.0.0:
+ansi-styles@^4.0.0, ansi-styles@^4.1.0:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
   integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
@@ -1079,10 +1341,10 @@
   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-back@^6.2.2:
+  version "6.2.2"
+  resolved "https://registry.yarnpkg.com/array-back/-/array-back-6.2.2.tgz#f567d99e9af88a6d3d2f9dfcc21db6f9ba9fd157"
+  integrity sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==
 
 array-union@^2.1.0:
   version "2.1.0"
@@ -1102,14 +1364,9 @@
     lodash "^4.17.14"
 
 axe-core@^4.3.3:
-  version "4.6.3"
-  resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.6.3.tgz#fc0db6fdb65cc7a80ccf85286d91d64ababa3ece"
-  integrity sha512-/BQzOX780JhsxDnPpH4ZiyrJAzcd8AfzFPkv+89veFSr1rcMjuq2JDCwypKaPeB6ljHp9KjXhPpjgCvQlWYuqg==
-
-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==
+  version "4.8.2"
+  resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.8.2.tgz#2f6f3cde40935825cf4465e3c1c9e77b240ff6ae"
+  integrity sha512-/dlp0fxyM3R8YW7MFzaHWXrf4zzbr0vaYb23VBFCl83R7nWNPg/yaQw2Dc8jzCMmDVLhSdzH8MjrsuIUuvX+6g==
 
 base64-js@^1.3.1:
   version "1.5.1"
@@ -1130,14 +1387,6 @@
     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"
@@ -1189,14 +1438,21 @@
   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==
+chai-a11y-axe@^1.5.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/chai-a11y-axe/-/chai-a11y-axe-1.5.0.tgz#aafa37f91f53baeafe98219768e5dee8776cf655"
+  integrity sha512-V/Vg/zJDr9aIkaHJ2KQu7lGTQQm5ZOH4u1k5iTMvIXuSVlSuUo0jcSpSqf9wUn9zl6oQXa4e4E0cqH18KOgKlQ==
   dependencies:
     axe-core "^4.3.3"
 
-chalk@^2.0.0, chalk@^2.4.2:
+chalk-template@^0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/chalk-template/-/chalk-template-0.4.0.tgz#692c034d0ed62436b9062c1707fadcd0f753204b"
+  integrity sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==
+  dependencies:
+    chalk "^4.1.2"
+
+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==
@@ -1205,6 +1461,14 @@
     escape-string-regexp "^1.0.5"
     supports-color "^5.3.0"
 
+chalk@^4.1.2:
+  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"
+
 chokidar@^3.4.3:
   version "3.5.3"
   resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
@@ -1226,15 +1490,22 @@
   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==
+  version "0.15.2"
+  resolved "https://registry.yarnpkg.com/chrome-launcher/-/chrome-launcher-0.15.2.tgz#4e6404e32200095fdce7f6a1e1004f9bd36fa5da"
+  integrity sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==
   dependencies:
     "@types/node" "*"
     escape-string-regexp "^4.0.0"
     is-wsl "^2.2.0"
     lighthouse-logger "^1.0.0"
 
+chromium-bidi@0.4.7:
+  version "0.4.7"
+  resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-0.4.7.tgz#4c022c2b0fb1d1c9b571fadf373042160e71d236"
+  integrity sha512-6+mJuFXwTMU6I3vYLs6IL8A1DyQTPjCfIL971X0aMPVGRbGnNfl6i6Cl0NMbxi2bRYLGESt9T2ZIMRM5PAEcIQ==
+  dependencies:
+    mitt "3.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"
@@ -1242,6 +1513,15 @@
   dependencies:
     restore-cursor "^3.1.0"
 
+cliui@^8.0.1:
+  version "8.0.1"
+  resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa"
+  integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==
+  dependencies:
+    string-width "^4.2.0"
+    strip-ansi "^6.0.1"
+    wrap-ansi "^7.0.0"
+
 clone@^2.1.2:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
@@ -1286,7 +1566,7 @@
   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:
+command-line-args@^5.1.1, command-line-args@^5.2.1:
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.1.tgz#c44c32e437a57d7c51157696893c5909e9cec42e"
   integrity sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==
@@ -1296,20 +1576,15 @@
     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==
+command-line-usage@^7.0.0, command-line-usage@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-7.0.1.tgz#e540afef4a4f3bc501b124ffde33956309100655"
+  integrity sha512-NCyznE//MuTjwi3y84QVUGEOT+P5oto1e1Pk/jFPVdPPfsG03qpTIl3yw6etR+v73d0lXsoojRpvbru2sqePxQ==
   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==
+    array-back "^6.2.2"
+    chalk-template "^0.4.0"
+    table-layout "^3.0.0"
+    typical "^7.1.1"
 
 content-disposition@~0.5.2:
   version "0.5.4"
@@ -1323,11 +1598,16 @@
   resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918"
   integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
 
-convert-source-map@^1.6.0, convert-source-map@^1.7.0:
+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@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
+  integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==
+
 cookies@~0.8.0:
   version "0.8.0"
   resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.8.0.tgz#1293ce4b391740a8406e3c9870e828c4b54f3f90"
@@ -1337,9 +1617,9 @@
     keygrip "~1.1.0"
 
 crelt@^1.0.5:
-  version "1.0.5"
-  resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.5.tgz#57c0d52af8c859e354bace1883eb2e1eb182bb94"
-  integrity sha512-+BO9wPPi+DWTDcNYhr/W90myha8ptzftZT+LwcmUbbok0rcP/fequmFYCw8NMoH7pkAZQzU78b3kYrlua5a9eA==
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72"
+  integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==
 
 cross-fetch@3.1.5:
   version "3.1.5"
@@ -1379,15 +1659,10 @@
   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.3.0"
-  resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.0.tgz#65491893ec47756d44719ae520e0e2609233b59b"
-  integrity sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og==
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
+  integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
 
 define-lazy-prop@^2.0.0:
   version "2.0.0"
@@ -1419,10 +1694,10 @@
   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==
+devtools-protocol@0.0.1107588:
+  version "0.0.1107588"
+  resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1107588.tgz#f8cac707840b97cc30b029359341bcbbb0ad8ffa"
+  integrity sha512-yIR+pG9x65Xko7bErCUSQaDLrO/P1p3JUzEk7JCU4DowPcGHkTGUGQapcfcLc4qj0UaALwZ+cr0riFgiqpixcg==
 
 diff@^5.0.0:
   version "5.1.0"
@@ -1464,136 +1739,42 @@
   integrity sha512-5ecWhU5gt0a5G05nmQcgCxP5HperSMxLDzvWlT5U+ZSKkuDK0rJ3dbCQny6/vSCIXjwrhwSecXBbw1alr295hQ==
 
 es-module-lexer@^1.0.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.2.0.tgz#812264973b613195ba214f69a84e05b0f4241a67"
-  integrity sha512-2BMfqBDeVCcOlLaL1ZAfp+D868SczNpKArrTM3dhpd7dK/OVlogzY15qpUngt+LMTq5UC/csb9vVQAgupucSbA==
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.3.1.tgz#c1b0dd5ada807a3b3155315911f364dc4e909db1"
+  integrity sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==
 
-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==
+"esbuild@^0.16 || ^0.17":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.17.19.tgz#087a727e98299f0462a3d0bcdd9cd7ff100bd955"
+  integrity sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==
   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"
+    "@esbuild/android-arm" "0.17.19"
+    "@esbuild/android-arm64" "0.17.19"
+    "@esbuild/android-x64" "0.17.19"
+    "@esbuild/darwin-arm64" "0.17.19"
+    "@esbuild/darwin-x64" "0.17.19"
+    "@esbuild/freebsd-arm64" "0.17.19"
+    "@esbuild/freebsd-x64" "0.17.19"
+    "@esbuild/linux-arm" "0.17.19"
+    "@esbuild/linux-arm64" "0.17.19"
+    "@esbuild/linux-ia32" "0.17.19"
+    "@esbuild/linux-loong64" "0.17.19"
+    "@esbuild/linux-mips64el" "0.17.19"
+    "@esbuild/linux-ppc64" "0.17.19"
+    "@esbuild/linux-riscv64" "0.17.19"
+    "@esbuild/linux-s390x" "0.17.19"
+    "@esbuild/linux-x64" "0.17.19"
+    "@esbuild/netbsd-x64" "0.17.19"
+    "@esbuild/openbsd-x64" "0.17.19"
+    "@esbuild/sunos-x64" "0.17.19"
+    "@esbuild/win32-arm64" "0.17.19"
+    "@esbuild/win32-ia32" "0.17.19"
+    "@esbuild/win32-x64" "0.17.19"
+
+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:
   version "1.0.3"
@@ -1632,9 +1813,9 @@
     "@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==
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.1.tgz#784b4e897340f3dbbef17413b3f11acf03c874c4"
+  integrity sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==
   dependencies:
     "@nodelib/fs.stat" "^2.0.2"
     "@nodelib/fs.walk" "^1.2.3"
@@ -1670,14 +1851,6 @@
   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"
@@ -1688,28 +1861,29 @@
   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==
+  version "2.3.3"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
+  integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
 
 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-caller-file@^2.0.5:
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
+  integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
+
 get-intrinsic@^1.0.2:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.0.tgz#7ad1dc0535f3a2904bba075772763e5051f6d05f"
-  integrity sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.1.tgz#d295644fed4505fc9cde952c37ee12b477a83d82"
+  integrity sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==
   dependencies:
     function-bind "^1.1.1"
     has "^1.0.3"
+    has-proto "^1.0.1"
     has-symbols "^1.0.3"
 
 get-stream@^5.1.0:
@@ -1731,18 +1905,6 @@
   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"
@@ -1765,6 +1927,11 @@
   resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
   integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
 
+has-proto@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.1.tgz#1885c1305538958aff469fef37937c22795408e0"
+  integrity sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==
+
 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"
@@ -1859,24 +2026,16 @@
   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==
 
+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==
+
 ip@^1.1.5:
   version "1.1.8"
   resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.8.tgz#ae05948f6b075435ed3307acce04629da8cdbf48"
@@ -1896,10 +2055,10 @@
   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==
+is-core-module@^2.13.0:
+  version "2.13.0"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.0.tgz#bb52aa6e2cbd49a30c2ba68c42bf3435ba6072db"
+  integrity sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==
   dependencies:
     has "^1.0.3"
 
@@ -1959,29 +2118,29 @@
   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==
+isbinaryfile@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-5.0.0.tgz#034b7e54989dab8986598cbcea41f66663c65234"
+  integrity sha512-UDdnyGvMajJUWCkib7Cei/dvyJrrvo4FIrsvSFWdPpXSUorzXrDJ0S+X5Q4ZlasfPjca4yqCNNsjbCeiy8FFeg==
 
 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==
+istanbul-lib-report@^3.0.0, istanbul-lib-report@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d"
+  integrity sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==
   dependencies:
     istanbul-lib-coverage "^3.0.0"
-    make-dir "^3.0.0"
+    make-dir "^4.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==
+  version "3.1.6"
+  resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.6.tgz#2544bcab4768154281a2f0870471902704ccaa1a"
+  integrity sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==
   dependencies:
     html-escaper "^2.0.0"
     istanbul-lib-report "^3.0.0"
@@ -2041,9 +2200,9 @@
     koa-send "^5.0.0"
 
 koa@^2.13.0:
-  version "2.14.1"
-  resolved "https://registry.yarnpkg.com/koa/-/koa-2.14.1.tgz#defb9589297d8eb1859936e777f3feecfc26925c"
-  integrity sha512-USJFyZgi2l0wDgqkfD27gL4YGno7TfUkcmOe6UOLFOVuN+J7FwnNu4Dydl4CUQzraM1lBAiGed0M9OVJoT0Kqw==
+  version "2.14.2"
+  resolved "https://registry.yarnpkg.com/koa/-/koa-2.14.2.tgz#a57f925c03931c2b4d94b19d2ebf76d3244863fc"
+  integrity sha512-VFI2bpJaodz6P7x2uyLiX6RLYpZmOJqNmoCst/Yyd7hQlszyPwG/I9CQJ63nOtKSxpt5M7NH67V6nJL2BwCl7g==
   dependencies:
     accepts "^1.3.5"
     cache-content-type "^1.0.0"
@@ -2070,43 +2229,67 @@
     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==
+  version "1.4.2"
+  resolved "https://registry.yarnpkg.com/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz#aef90f9e97cd81db367c7634292ee22079280aaa"
+  integrity sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==
   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==
+lit-element@^3.3.0:
+  version "3.3.3"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.3.3.tgz#10bc19702b96ef5416cf7a70177255bfb17b3209"
+  integrity sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==
   dependencies:
+    "@lit-labs/ssr-dom-shim" "^1.1.0"
     "@lit/reactive-element" "^1.3.0"
-    lit-html "^2.2.0"
+    lit-html "^2.8.0"
 
-lit-html@^2.0.0, lit-html@^2.2.0, lit-html@^2.6.0:
-  version "2.6.1"
-  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.6.1.tgz#eb29f0b0c2ab54ea77379db11fc011b0c71f1cda"
-  integrity sha512-Z3iw+E+3KKFn9t2YKNjsXNEu/LRLI98mtH/C6lnFg7kvaqPIzPn124Yd4eT/43lyqrejpc5Wb6BHq3fdv4S8Rw==
+lit-element@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-4.0.0.tgz#8343891bc9159a5fcb7f534914b37f2c0161e036"
+  integrity sha512-N6+f7XgusURHl69DUZU6sTBGlIN+9Ixfs3ykkNDfgfTkDYGGOWwHAYBhDqVswnFGyWgQYR2KiSpu4J76Kccs/A==
+  dependencies:
+    "@lit-labs/ssr-dom-shim" "^1.1.2-pre.0"
+    "@lit/reactive-element" "^2.0.0"
+    lit-html "^3.0.0"
+
+lit-html@^2.0.0, lit-html@^2.8.0:
+  version "2.8.0"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.8.0.tgz#96456a4bb4ee717b9a7d2f94562a16509d39bffa"
+  integrity sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==
   dependencies:
     "@types/trusted-types" "^2.0.2"
 
-lit@^2.0.0, lit@^2.2.3:
-  version "2.6.1"
-  resolved "https://registry.yarnpkg.com/lit/-/lit-2.6.1.tgz#5951a2098b9bde5b328c73b55c15fdc0eefd96d7"
-  integrity sha512-DT87LD64f8acR7uVp7kZfhLRrHkfC/N4BVzAtnw9Yg8087mbBJ//qedwdwX0kzDbxgPccWRW6mFwGbRQIxy0pw==
+lit-html@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-3.0.0.tgz#77d6776ee488642c74c5575315ef81aa09d24ea9"
+  integrity sha512-DNJIE8dNY0dQF2Gs0sdMNUppMQT2/CvV4OVnSdg7BXAsGqkVwsE5bqQ04POfkYH5dBIuGnJYdFz5fYYyNnOxiA==
+  dependencies:
+    "@types/trusted-types" "^2.0.2"
+
+lit@^2.0.0:
+  version "2.8.0"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-2.8.0.tgz#4d838ae03059bf9cafa06e5c61d8acc0081e974e"
+  integrity sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==
   dependencies:
     "@lit/reactive-element" "^1.6.0"
-    lit-element "^3.2.0"
-    lit-html "^2.6.0"
+    lit-element "^3.3.0"
+    lit-html "^2.8.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==
+lit@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-3.0.0.tgz#204bd65935892a73670471e893ee8ca55d2f9a3b"
+  integrity sha512-nQ0teRzU1Kdj++VdmttS2WvIen8M79wChJ6guRKIIym2M3Ansg3Adj9O6yuQh2IpjxiUXlNuS81WKlQ4iL3BmA==
   dependencies:
-    p-locate "^4.1.0"
+    "@lit/reactive-element" "^2.0.0"
+    lit-element "^4.0.0"
+    lit-html "^3.0.0"
+
+lodash.assignwith@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/lodash.assignwith/-/lodash.assignwith-4.2.0.tgz#127a97f02adc41751a954d24b0de17e100e038eb"
+  integrity sha512-ZznplvbvtjK2gMvnQ1BR/zqPFZmS6jbK4p+6Up4xcRYA7yMIwxHCfbTcrYxXKzzqLsQ05eJPVznEW3tuwV7k1g==
 
 lodash.camelcase@^4.3.0:
   version "4.3.0"
@@ -2140,12 +2323,17 @@
   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==
+lru-cache@^8.0.4:
+  version "8.0.5"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-8.0.5.tgz#983fe337f3e176667f8e567cfcce7cb064ea214e"
+  integrity sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==
+
+make-dir@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e"
+  integrity sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==
   dependencies:
-    semver "^6.0.0"
+    semver "^7.5.3"
 
 marky@^1.2.2:
   version "1.2.5"
@@ -2187,18 +2375,16 @@
   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.8"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
   integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
 
+mitt@3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.0.tgz#69ef9bd5c80ff6f57473e8d89326d01c414be0bd"
+  integrity sha512-7dX2/10ITVyqh4aOSVI9gdape+t9l2/8QxHrFmUXu4EEUpdlxl6RudZUPZoc+zuY2hk1j7XxVroIVIan/pD/SQ==
+
 mkdirp-classic@^0.5.2:
   version "0.5.3"
   resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
@@ -2237,9 +2423,9 @@
   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==
+  version "3.3.6"
+  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"
+  integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==
 
 negotiator@0.6.3:
   version "0.6.3"
@@ -2281,7 +2467,7 @@
   dependencies:
     ee-first "1.1.1"
 
-once@^1.3.0, once@^1.3.1, once@^1.4.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==
@@ -2309,25 +2495,6 @@
     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"
@@ -2338,12 +2505,7 @@
   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:
+path-is-absolute@1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
   integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==
@@ -2375,13 +2537,6 @@
   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"
@@ -2414,28 +2569,27 @@
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f"
   integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==
 
-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==
+puppeteer-core@^19.8.1:
+  version "19.11.1"
+  resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-19.11.1.tgz#4c63d7a0a6cd268ff054ebcac315b646eee32667"
+  integrity sha512-qcuC2Uf0Fwdj9wNtaTZ2OvYRraXpAK+puwwVW8ofOhOgLPZyz1c68tsorfIZyCUOpyBisjr+xByu7BMbEYMepA==
   dependencies:
+    "@puppeteer/browsers" "0.5.0"
+    chromium-bidi "0.4.7"
     cross-fetch "3.1.5"
     debug "4.3.4"
-    devtools-protocol "0.0.981744"
+    devtools-protocol "0.0.1107588"
     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"
+    ws "8.13.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==
+  version "6.11.2"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.2.tgz#64bea51f12c1f5da1bc01496f48ffcff7c69d7d9"
+  integrity sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==
   dependencies:
     side-channel "^1.0.4"
 
@@ -2455,9 +2609,9 @@
     unpipe "1.0.0"
 
 readable-stream@^3.1.1, readable-stream@^3.4.0:
-  version "3.6.1"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.1.tgz#f9f9b5f536920253b3d26e7660e7da4ccff9bb62"
-  integrity sha512-+rQmrWMYGA90yenhTYsLWAsLsqVC8osOw6PKE1HDYiO0gdPeKe/xDHNzIAIn4C91YQ6oenEhfYqqc1883qHbjQ==
+  version "3.6.2"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967"
+  integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==
   dependencies:
     inherits "^2.0.3"
     string_decoder "^1.1.1"
@@ -2470,10 +2624,10 @@
   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==
+require-directory@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
+  integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==
 
 resolve-path@^1.4.0:
   version "1.4.0"
@@ -2484,11 +2638,11 @@
     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==
+  version "1.22.6"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.6.tgz#dd209739eca3aef739c626fea1b4f3c506195362"
+  integrity sha512-njhxM7mV12JfufShqGy3Rz8j11RPdLy4xi15UurGJeoHLfJpVXKdh3ueuOqbYUcDZnffr6X739JBo5LzyahEsw==
   dependencies:
-    is-core-module "^2.9.0"
+    is-core-module "^2.13.0"
     path-parse "^1.0.7"
     supports-preserve-symlinks-flag "^1.0.0"
 
@@ -2505,13 +2659,6 @@
   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"
@@ -2543,15 +2690,10 @@
   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==
+semver@^7.3.4, semver@^7.5.3:
+  version "7.5.4"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
+  integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
   dependencies:
     lru-cache "^6.0.0"
 
@@ -2579,7 +2721,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:
+sinon@^13.0.2:
   version "13.0.2"
   resolved "https://registry.yarnpkg.com/sinon/-/sinon-13.0.2.tgz#c6a8ddd655dc1415bbdc5ebf0e5b287806850c3a"
   integrity sha512-KvOrztAVqzSJWMDoxM4vM+GPys1df2VBoXm+YciyB/OLMamfS3VXh3oGh5WtrAGSzrgczNWFFY22oKb7Fi5eeA==
@@ -2620,7 +2762,12 @@
   resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
   integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==
 
-string-width@^4.1.0:
+stream-read-all@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/stream-read-all/-/stream-read-all-3.0.1.tgz#60762ae45e61d93ba0978cda7f3913790052ad96"
+  integrity sha512-EWZT9XOceBPlVJRrYcykW8jyRSZYbkb/0ZK36uLEmoWVO5gxBOnntNTseNzfREsqxqdfEGQrD8SXQ3QWbBmq8A==
+
+string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
   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==
@@ -2643,10 +2790,10 @@
   dependencies:
     ansi-regex "^5.0.1"
 
-style-mod@^4.0.0:
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.0.3.tgz#136c4abc905f82a866a18b39df4dc08ec762b1ad"
-  integrity sha512-78Jv8kYJdjbvRwwijtCevYADfsI0lGzYJe4mMFdceO8l75DFFDoqBhR1jVDicDRRaX4//g1u9wKeo+ztc2h1Rw==
+style-mod@^4.0.0, style-mod@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.1.0.tgz#a313a14f4ae8bb4d52878c0053c4327fb787ec09"
+  integrity sha512-Ca5ib8HrFn+f+0n4N4ScTIA9iTOQ7MaGS1ylHcoVqW9J7w2w8PzN6g9gKmTYgGEBH8e120+RCmhpje6jC5uGWA==
 
 supports-color@^5.3.0:
   version "5.5.0"
@@ -2667,15 +2814,18 @@
   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==
+table-layout@^3.0.0:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-3.0.2.tgz#69c2be44388a5139b48c59cf21e73b488021769a"
+  integrity sha512-rpyNZYRw+/C+dYkcQ3Pr+rLxW4CfHpXjPDnG7lYhdRoUcZTUt+KEsX+94RGp/aVp/MQU35JCITv2T/beY4m+hw==
   dependencies:
-    array-back "^4.0.1"
-    deep-extend "~0.6.0"
-    typical "^5.2.0"
-    wordwrapjs "^4.0.0"
+    "@75lb/deep-merge" "^1.1.1"
+    array-back "^6.2.2"
+    command-line-args "^5.2.1"
+    command-line-usage "^7.0.0"
+    stream-read-all "^3.0.1"
+    typical "^7.1.1"
+    wordwrapjs "^5.1.0"
 
 tar-fs@2.1.1:
   version "2.1.1"
@@ -2760,15 +2910,15 @@
   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==
+typical@^7.1.1:
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/typical/-/typical-7.1.1.tgz#ba177ab7ab103b78534463ffa4c0c9754523ac1f"
+  integrity sha512-T+tKVNs6Wu7IWiAce5BgMd7OZfNYUndHwc5MknN+UHOudi7sGZzuHdCadllRuqJ3fPtgFtIH9+lt9qRv6lmpfA==
 
-ua-parser-js@^1.0.2:
-  version "1.0.33"
-  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.33.tgz#f21f01233e90e7ed0f059ceab46eb190ff17f8f4"
-  integrity sha512-RqshF7TPTE0XLYAqmjlu5cLLuGdKrNu9O1KLA/qp39QtbZwuzwv1dT46DZSopoUMsYgXpB3Cv8a03FI8b74oFQ==
+ua-parser-js@^1.0.33:
+  version "1.0.36"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.36.tgz#a9ab6b9bd3a8efb90bb0816674b412717b7c428c"
+  integrity sha512-znuyCIXzl8ciS3+y3fHJI/2OhQIXbXw9MWC/o3qwyR+RGppjZHrM27CGFSKCJXi2Kctiz537iOu2KnXs1lMQhw==
 
 unbzip2-stream@1.4.3:
   version "1.4.3"
@@ -2788,14 +2938,14 @@
   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==
+v8-to-istanbul@^9.0.1:
+  version "9.1.0"
+  resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz#1b83ed4e397f58c85c266a570fc2558b5feb9265"
+  integrity sha512-6z3GW9x8G1gd+JIIgQQQxXuiJtCXeAjp6RaPEPLv62mH3iPHPxV6W3robxtCzNErRo6ZwTmzWhsbNvjyEBKzKA==
   dependencies:
+    "@jridgewell/trace-mapping" "^0.3.12"
     "@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"
@@ -2803,9 +2953,9 @@
   integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
 
 w3c-keyname@^2.2.4:
-  version "2.2.6"
-  resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.6.tgz#8412046116bc16c5d73d4e612053ea10a189c85f"
-  integrity sha512-f+fciywl1SJEniZHD6H+kUO8gOnwIr7f4ijKA6+ZvJFjeGi1r4PDLl53Ayud9O/rk64RqgoQine0feoeOU0kXg==
+  version "2.2.8"
+  resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5"
+  integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==
 
 webidl-conversions@^3.0.0:
   version "3.0.1"
@@ -2833,13 +2983,10 @@
     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"
+wordwrapjs@^5.1.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-5.1.0.tgz#4c4d20446dcc670b14fa115ef4f8fd9947af2b3a"
+  integrity sha512-JNjcULU2e4KJwUNv6CHgI46UvDGitb6dGryHajXTDiLgg1/RiGoPSDw4kZfYnwGtEXf2ZMeIewDQgFGzkCB2Sg==
 
 wrap-ansi@^6.2.0:
   version "6.2.0"
@@ -2850,26 +2997,58 @@
     string-width "^4.1.0"
     strip-ansi "^6.0.0"
 
+wrap-ansi@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
+  integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
+  dependencies:
+    ansi-styles "^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@8.13.0:
+  version "8.13.0"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0"
+  integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==
 
 ws@^7.4.2:
   version "7.5.9"
   resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591"
   integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==
 
+y18n@^5.0.5:
+  version "5.0.8"
+  resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
+  integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
+
 yallist@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
   integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
 
+yargs-parser@^21.1.1:
+  version "21.1.1"
+  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35"
+  integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==
+
+yargs@17.7.1:
+  version "17.7.1"
+  resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.1.tgz#34a77645201d1a8fc5213ace787c220eabbd0967"
+  integrity sha512-cwiTb08Xuv5fqF4AovYacTFNxk62th7LKJ6BL9IGUpTJrWoU7/7WdQGTP2SjKf1dUNBGzDd28p/Yfs/GI6JrLw==
+  dependencies:
+    cliui "^8.0.1"
+    escalade "^3.1.1"
+    get-caller-file "^2.0.5"
+    require-directory "^2.1.1"
+    string-width "^4.2.3"
+    y18n "^5.0.5"
+    yargs-parser "^21.1.1"
+
 yauzl@^2.10.0:
   version "2.10.0"
   resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index 0848925..b8c5666 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -153,7 +153,7 @@
 ```
 
 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
+[Issue 40015119](https://issues.gerritcodereview.com/issues/40015119) for
 fixing this issue.
 
 *NOTE* You can use any other cdn here, for example: https://cdn.googlesource.com/polygerrit_ui/678.0
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index e18a3af..e0fe3b5 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -176,8 +176,6 @@
     'jsdoc/implements-on-classes': 2,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-match-description
     'jsdoc/match-description': 0,
-    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-newline-after-description
-    'jsdoc/newline-after-description': 2,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-no-types
     'jsdoc/no-types': 0,
     // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-no-undefined-types
@@ -236,19 +234,6 @@
     ],
   },
 
-  // List of allowed globals in all files
-  globals: {
-    // Polygerrit global variables.
-    // You must not add anything new in this list!
-    // Instead export variables from modules
-    // TODO(dmfilippov): Remove global variables from polygerrit
-    // Global variables from 3rd party libraries.
-    // You should not add anything in this list, always try to import
-    // If import is not possible - you can extend this list
-    ShadyCSS: 'readonly',
-    linkify: 'readonly',
-    security: 'readonly',
-  },
   overrides: [
     {
       files: ['.eslintrc.js', '.eslintrc-bazel.js'],
diff --git a/polygerrit-ui/app/api/annotation.ts b/polygerrit-ui/app/api/annotation.ts
index c394ef7..7cf200f 100644
--- a/polygerrit-ui/app/api/annotation.ts
+++ b/polygerrit-ui/app/api/annotation.ts
@@ -3,8 +3,13 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {CoverageRange} from './diff';
-import {ChangeInfo} from './rest-api';
+import {
+  CoverageRange,
+  FileRange,
+  GrDiff,
+  TokenHighlightEventDetails,
+} from './diff';
+import {BasePatchSetNum, ChangeInfo, RevisionPatchSetNum} from './rest-api';
 
 /**
  * This is the callback object that Gerrit calls once for each diff. Gerrit
@@ -19,6 +24,21 @@
   change?: ChangeInfo
 ) => Promise<Array<CoverageRange> | undefined>;
 
+export declare interface DiffDetails {
+  change: ChangeInfo;
+  basePatchNum: BasePatchSetNum;
+  patchNum: RevisionPatchSetNum;
+  fileRange: FileRange;
+  /** @deprecated rely on fileRange.path */
+  path: string;
+  diffElement: GrDiff;
+}
+
+export declare type TokenHoverListener = (
+  diff: DiffDetails,
+  highlight?: TokenHighlightEventDetails
+) => void;
+
 export declare interface AnnotationPluginApi {
   /**
    * The specified function will be called when a gr-diff component is built,
@@ -29,4 +49,15 @@
    * provider of the first call.
    */
   setCoverageProvider(coverageProvider: CoverageProvider): void;
+
+  /**
+   * Experimental endpoint for calling a function when a gr-diff token is
+   * hovered.
+   *
+   * The callback receives details of the diff itself and of the highlighted
+   * token.
+   *
+   * TODO: Replace with a more general addDiffLayer() endpoint.
+   */
+  addTokenHoverListener(callback: TokenHoverListener): void;
 }
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 4bf253d..24110ed 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -10,7 +10,7 @@
  */
 
 import {CursorMoveResult} from './core';
-import {CommentRange} from './rest-api';
+import {BasePatchSetNum, CommentRange, RevisionPatchSetNum} from './rest-api';
 
 /**
  * Diff type in preferences
@@ -251,6 +251,10 @@
    * property on <gr-diff>. TODO: Migrate usages to RenderPreferences.
    */
   view_mode?: DiffViewMode;
+  can_comment?: boolean;
+  show_newline_warning_left?: boolean;
+  show_newline_warning_right?: boolean;
+  use_new_image_diff_ui?: boolean;
 }
 
 /**
@@ -302,11 +306,25 @@
   code_range: LineRange;
 }
 
-/** LOST LineNumber is for ported comments without a range, they have their own
- *  line number and are added on top of the FILE row in gr-diff
+export interface FileRange {
+  basePath?: string;
+  path: string;
+}
+
+export interface PatchRange {
+  patchNum: RevisionPatchSetNum;
+  basePatchNum: BasePatchSetNum;
+}
+
+/**
+ * LOST LineNumber is for ported comments without a range, they have their own
+ * line number and are added on top of the FILE row in <gr-diff>.
  */
 export declare type LineNumber = number | 'FILE' | 'LOST';
 
+export const FILE: LineNumber = 'FILE';
+export const LOST: LineNumber = 'LOST';
+
 /** The detail of the 'create-comment' event dispatched by gr-diff. */
 export declare interface CreateCommentEventDetail {
   side: Side;
@@ -360,6 +378,7 @@
 export declare interface DiffContextExpandedExternalDetail {
   expandedLines: number;
   buttonType: ContextButtonType;
+  numLines: number;
 }
 
 /**
diff --git a/polygerrit-ui/app/api/package-lock.json b/polygerrit-ui/app/api/package-lock.json
new file mode 100644
index 0000000..75fb4dc
--- /dev/null
+++ b/polygerrit-ui/app/api/package-lock.json
@@ -0,0 +1,5 @@
+{
+  "name": "@gerritcodereview/typescript-api",
+  "version": "3.8.0",
+  "lockfileVersion": 1
+}
diff --git a/polygerrit-ui/app/api/package.json b/polygerrit-ui/app/api/package.json
index 7df755b..a125b8f 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.8.0",
+  "version": "3.9.0-SNAPSHOT",
   "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 d3d012d..684429a 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -15,6 +15,7 @@
 import {RestPluginApi} from './rest';
 import {HookApi, RegisterOptions} from './hook';
 import {StylePluginApi} from './styles';
+import {SuggestionsPluginApi} from './suggestions';
 
 export enum TargetElement {
   CHANGE_ACTIONS = 'changeactions',
@@ -32,6 +33,7 @@
   REVERT_SUBMISSION = 'revert_submission',
   POST_REVERT = 'postrevert',
   ADMIN_MENU_LINKS = 'admin-menu-links',
+  SHOW_DIFF = 'showdiff',
 }
 
 export declare interface PluginApi {
@@ -57,6 +59,7 @@
   changeActions(): ChangeActionsPluginApi;
   changeReply(): ChangeReplyPluginApi;
   checks(): ChecksPluginApi;
+  suggestions(): SuggestionsPluginApi;
   eventHelper(element: Node): EventHelperPluginApi;
   getPluginName(): string;
   hook<T extends HTMLElement>(
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 993f24d..045aee5 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -367,6 +367,7 @@
   topic?: TopicName;
   attention_set?: IdToAttentionSetMap;
   hashtags?: Hashtag[];
+  custom_keyed_values?: CustomKeyedValues;
   change_id: ChangeId;
   subject: string;
   status: ChangeStatus;
@@ -375,7 +376,6 @@
   submitted?: Timestamp;
   submitter?: AccountInfo;
   starred?: boolean; // not set if false
-  stars?: StarLabel[];
   submit_type?: SubmitType;
   mergeable?: boolean;
   submittable?: boolean;
@@ -463,6 +463,7 @@
   [name: string]: CommentLinkInfo;
 }
 
+/** 40 char string, see shorten() util, if you want 7 chars. */
 export type CommitId = BrandType<string, '_commitId'>;
 
 /**
@@ -516,6 +517,31 @@
 }
 
 /**
+ * The CommentInfo entity contains information about an inline comment.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
+ */
+export interface CommentInfo {
+  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;
+  line?: number;
+  range?: CommentRange;
+  in_reply_to?: UrlEncodedCommentId;
+  message?: string;
+  author?: AccountInfo;
+  tag?: string;
+  unresolved?: boolean;
+  change_message_id?: string;
+  commit_id?: string;
+  context_lines?: ContextLine[];
+  source_content_type?: string;
+}
+
+/**
  * The CommentRange entity describes the range of an inline comment.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-range
  *
@@ -542,6 +568,15 @@
   end_character: number;
 }
 
+/**
+ * The side on which the comment was added
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
+ */
+export enum CommentSide {
+  REVISION = 'REVISION',
+  PARENT = 'PARENT',
+}
+
 export declare interface ConfigListParameterInfo
   extends ConfigParameterInfoBase {
   type: ConfigParameterInfoType.LIST;
@@ -571,6 +606,15 @@
   inherited_value?: string;
 }
 
+/**
+ * The ContextLine entity contains the line number and line text of a single line of the source file content.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#context-line
+ */
+export interface ContextLine {
+  line_number: number;
+  context_line: string;
+}
+
 // https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#contributor-agreement-info
 export declare interface ContributorAgreementInfo {
   name: string;
@@ -664,6 +708,7 @@
   // The following property is missed in doc
   primary_weblink_name?: string;
   instance_id?: string;
+  default_branch?: string;
 }
 
 export type GitRef = BrandType<string, '_gitRef'>;
@@ -732,6 +777,12 @@
 
 export type Hashtag = BrandType<string, '_hashtag'>;
 
+export type CustomKey = BrandType<string, '_custom_key'>;
+export type CustomValue = BrandType<string, '_custom_value'>;
+
+// A map from CustomKey to CustomValue
+export type CustomKeyedValues = {[key: CustomKey]: CustomValue};
+
 export type IdToAttentionSetMap = {[accountId: string]: AttentionSetInfo};
 
 /**
@@ -975,7 +1026,6 @@
  * fields are returned by default.  Additional fields can be obtained by
  * adding o parameters as described in Query Changes.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#revision-info
- * basePatchNum is present in case RevisionInfo is of type 'edit'
  */
 export declare interface RevisionInfo {
   kind: RevisionKind;
@@ -990,7 +1040,22 @@
   commit_with_footers?: string;
   push_certificate?: PushCertificateInfo;
   description?: string;
-  basePatchNum?: BasePatchSetNum;
+  parents_data?: ParentInfo[];
+}
+
+/**
+ * The ParentInfo entity contains detailed information the parent commit of a revision.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#parent-info
+ * basePatchNum is present in case RevisionInfo is of type 'edit'
+ */
+export declare interface ParentInfo {
+  branch_name?: string;
+  commit_id?: CommitId;
+  is_merged_in_target_branch?: boolean;
+  change_id?: ChangeId;
+  change_number?: NumericChangeId;
+  patch_set_number?: PatchSetNumber;
+  change_status?: ChangeStatus;
 }
 
 export type SchemesInfoMap = {[name: string]: DownloadSchemeInfo};
@@ -1026,7 +1091,6 @@
  */
 export type SshdInfo = {};
 
-export type StarLabel = BrandType<string, '_startLabel'>;
 // Timestamps are given in UTC and have the format
 // "'yyyy-mm-dd hh:mm:ss.fffffffff'"
 // where "'ffffffffff'" represents nanoseconds.
@@ -1224,3 +1288,6 @@
 ): res is Base64FileContent {
   return (res as Base64FileContent).ok;
 }
+
+// The URL encoded UUID of the comment
+export type UrlEncodedCommentId = BrandType<string, '_urlEncodedCommentId'>;
diff --git a/polygerrit-ui/app/api/suggestions.ts b/polygerrit-ui/app/api/suggestions.ts
new file mode 100644
index 0000000..5dedad4
--- /dev/null
+++ b/polygerrit-ui/app/api/suggestions.ts
@@ -0,0 +1,48 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {CommentRange, NumericChangeId, RevisionPatchSetNum} from './rest-api';
+
+export declare interface SuggestionsPluginApi {
+  /**
+   * Must only be called once. You cannot register twice. You cannot unregister.
+   */
+  register(provider: SuggestionsProvider): void;
+}
+
+export declare interface SuggestCodeRequest {
+  prompt: string;
+  changeNumber: NumericChangeId;
+  patchsetNumber: RevisionPatchSetNum;
+  filePath: string;
+  range?: CommentRange;
+  lineNumber?: number;
+}
+
+export declare interface SuggestionsProvider {
+  /**
+   * Gerrit calls this method when ...
+   * - ... user types a comment draft
+   */
+  suggestCode(commentData: SuggestCodeRequest): Promise<SuggestCodeResponse>;
+}
+
+export declare interface SuggestCodeResponse {
+  responseCode: ResponseCode;
+  suggestions: Suggestion[];
+}
+
+export declare interface Suggestion {
+  replacement: string;
+  newRange?: CommentRange;
+}
+
+export enum ResponseCode {
+  OK = 'OK',
+  NO_SUGGESTION = 'NO_SUGGESTION',
+  OUT_OF_RANGE = 'OUT_OF_RANGE',
+  ERROR = 'ERROR',
+}
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index b9ed56b..24fb5c0 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -13,6 +13,7 @@
 import {
   AuthType,
   ChangeStatus,
+  CommentSide,
   ConfigParameterInfoType,
   DefaultDisplayNameConfig,
   EditableAccountField,
@@ -32,6 +33,7 @@
 export {
   AuthType,
   ChangeStatus,
+  CommentSide,
   ConfigParameterInfoType,
   DefaultDisplayNameConfig,
   EditableAccountField,
@@ -92,16 +94,13 @@
 
 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'
+  STATUS = 'Status',
 }
 
 /**
@@ -160,15 +159,6 @@
 }
 
 /**
- * The side on which the comment was added
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
- */
-export enum CommentSide {
-  REVISION = 'REVISION',
-  PARENT = 'PARENT',
-}
-
-/**
  * Allowed app themes
  * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
  */
@@ -273,6 +263,7 @@
     email_strategy: EmailStrategy.ATTENTION_SET_ONLY,
     default_base_for_merges: DefaultBase.AUTO_MERGE,
     allow_browser_notifications: false,
+    diff_page_sidebar: 'NONE',
   };
 }
 
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index ad59edd..f57afca 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -10,6 +10,7 @@
   STARTED_AS_GUEST = 'Started as guest',
   VISIBILILITY_HIDDEN = 'Visibility changed to hidden',
   VISIBILILITY_VISIBLE = 'Visibility changed to visible',
+  FOCUS = 'Focus changed',
   EXTENSION_DETECTED = 'Extension detected',
   PLUGINS_INSTALLED = 'Plugins installed',
   PLUGINS_FAILED = 'Some plugins failed to load',
@@ -95,6 +96,12 @@
   LCP = 'LCP',
   // WebVitals - Interaction to Next Paint (INP): measures responsiveness
   INP = 'INP',
+  // Time to load preview for a user suggested edit or a fix from checks
+  PREVIEW_FIX_LOAD = 'PreviewFixLoad',
+  // Time to apply fix for a user suggested edit or a fix from checks
+  APPLY_FIX_LOAD = 'ApplyFixLoad',
+  // Time to copy target to clipboard
+  COPY_TO_CLIPBOARD = 'CopyToClipboard',
 }
 
 export enum Interaction {
@@ -127,4 +134,16 @@
   CHANGE_ACTION_FIRED = 'change-action-fired',
   BUTTON_CLICK = 'button-click',
   LINK_CLICK = 'link-click',
+  USER_ACTIVE = 'user-active',
+  USER_PASSIVE = 'user-passive',
+  // User added generated suggestion to comment
+  GENERATE_SUGGESTION_ADDED = 'generate_suggestion_added',
+  // Request for generating suggestion (usually after user typed draft comment)
+  GENERATE_SUGGESTION_REQUEST = 'generate_suggestion_request',
+  // Response with suggestions
+  GENERATE_SUGGESTION_RESPONSE = 'generate_suggestion_response',
+  // User enabled generating suggestions (enabled is default)
+  GENERATE_SUGGESTION_ENABLED = 'generate_suggestion_enabled',
+  // User disabled generating suggestions
+  GENERATE_SUGGESTION_DISABLED = 'generate_suggestion_disabled',
 }
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 e15c240..116c723 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
@@ -27,7 +27,7 @@
 import {fire} 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 {grFormStyles} 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';
@@ -87,7 +87,7 @@
 
   static override get styles() {
     return [
-      formStyles,
+      grFormStyles,
       fontStyles,
       sharedStyles,
       css`
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 b3f1e96..ac2e7cc 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
@@ -19,7 +19,7 @@
 } from '../../../types/common';
 import {InheritedBooleanInfoConfiguredValue} from '../../../constants/constants';
 import {getAppContext} from '../../../services/app-context';
-import {formStyles} from '../../../styles/gr-form-styles';
+import {grFormStyles} 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';
@@ -30,9 +30,10 @@
 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 {formStyles} from '../../../styles/form-styles';
+import {branchName} from '../../../utils/patch-set-util';
 
 const SUGGESTIONS_LIMIT = 15;
-const REF_PREFIX = 'refs/heads/';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -94,6 +95,7 @@
 
   static override get styles() {
     return [
+      grFormStyles,
       formStyles,
       sharedStyles,
       css`
@@ -241,12 +243,9 @@
     if (!this.repoName) {
       return Promise.reject(new Error('missing repo name'));
     }
-    if (input.startsWith(REF_PREFIX)) {
-      input = input.substring(REF_PREFIX.length);
-    }
     return this.restApiService
       .getRepoBranches(
-        input,
+        branchName(input),
         this.repoName,
         SUGGESTIONS_LIMIT,
         /* offset=*/ undefined,
@@ -256,11 +255,7 @@
         if (!response) return [];
         const branches: Array<{name: BranchName}> = [];
         for (const branchInfo of response) {
-          let name: string = branchInfo.ref;
-          if (name.startsWith('refs/heads/')) {
-            name = name.substring('refs/heads/'.length);
-          }
-          branches.push({name: name as BranchName});
+          branches.push({name: branchName(branchInfo.ref)});
         }
         return branches;
       });
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 7428727..372e1e0 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
@@ -8,7 +8,7 @@
 import '../../../styles/shared-styles';
 import {GroupName} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
-import {formStyles} from '../../../styles/gr-form-styles';
+import {grFormStyles} 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';
@@ -40,7 +40,7 @@
 
   static override get styles() {
     return [
-      formStyles,
+      grFormStyles,
       sharedStyles,
       css`
         :host {
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 12f36ec..27bb836 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
@@ -8,7 +8,7 @@
 import '../../shared/gr-select/gr-select';
 import {BranchName, RepoName} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
-import {formStyles} from '../../../styles/gr-form-styles';
+import {grFormStyles} 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';
@@ -49,7 +49,7 @@
 
   static override get styles() {
     return [
-      formStyles,
+      grFormStyles,
       sharedStyles,
       css`
         :host {
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 1a70f2b..8393d81 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
@@ -16,7 +16,7 @@
 import {AutocompleteQuery} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getAppContext} from '../../../services/app-context';
 import {convertToString} from '../../../utils/string-util';
-import {formStyles} from '../../../styles/gr-form-styles';
+import {grFormStyles} 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.js';
@@ -26,6 +26,9 @@
 import {resolve} from '../../../models/dependency';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {ValueChangedEvent} from '../../../types/events';
+import {subscribe} from '../../lit/subscription-controller';
+import {configModelToken} from '../../../models/config/config-model';
+import {branchName} from '../../../utils/patch-set-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -54,7 +57,7 @@
   };
 
   /* private but used in test */
-  @state() defaultBranch?: BranchName;
+  @state() selectedDefaultBranch?: BranchName;
 
   /* private but used in test */
   @state() repoCreated = false;
@@ -65,6 +68,8 @@
   /* private but used in test */
   @state() repoOwnerId?: GroupId;
 
+  @state() private defaultBranch = 'master';
+
   private readonly query: AutocompleteQuery;
 
   private readonly queryGroups: AutocompleteQuery;
@@ -73,15 +78,26 @@
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly configModel = resolve(this, configModelToken);
+
   constructor() {
     super();
     this.query = (input: string) => this.getRepoSuggestions(input);
     this.queryGroups = (input: string) => this.getGroupSuggestions(input);
+    subscribe(
+      this,
+      () => this.configModel().serverConfig$,
+      config => {
+        this.defaultBranch = branchName(
+          config?.gerrit?.default_branch ?? 'master'
+        );
+      }
+    );
   }
 
   static override get styles() {
     return [
-      formStyles,
+      grFormStyles,
       sharedStyles,
       css`
         :host {
@@ -112,12 +128,15 @@
           </section>
           <section>
             <span class="title">Default Branch</span>
-            <iron-input
-              .bindValue=${convertToString(this.defaultBranch)}
-              @bind-value-changed=${this.handleBranchNameBindValueChanged}
-            >
-              <input id="defaultBranchNameInput" autocomplete="off" />
-            </iron-input>
+            <span class="value">
+              <gr-autocomplete
+                id="defaultBranchNameInput"
+                .text=${convertToString(this.selectedDefaultBranch)}
+                .placeholder=${`Optional, defaults to '${this.defaultBranch}'`}
+                @text-changed=${this.handleBranchNameBindValueChanged}
+              >
+              </gr-autocomplete>
+            </span>
           </section>
           <section>
             <span class="title">Rights inherit from</span>
@@ -190,7 +209,8 @@
   }
 
   async handleCreateRepo() {
-    if (this.defaultBranch) this.repoConfig.branches = [this.defaultBranch];
+    if (this.selectedDefaultBranch)
+      this.repoConfig.branches = [this.selectedDefaultBranch];
     if (this.repoOwnerId) this.repoConfig.owners = [this.repoOwnerId];
     const repoRegistered = await this.restApiService.createRepo(
       this.repoConfig
@@ -255,7 +275,7 @@
   }
 
   private handleBranchNameBindValueChanged(e: ValueChangedEvent) {
-    this.defaultBranch = e.detail.value as BranchName;
+    this.selectedDefaultBranch = e.detail.value as BranchName;
   }
 
   private handleCreateEmptyCommitBindValueChanged(
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 63b8c06..bc851a3 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
@@ -39,9 +39,9 @@
             </section>
             <section>
               <span class="title"> Default Branch </span>
-              <iron-input>
-                <input autocomplete="off" id="defaultBranchNameInput" />
-              </iron-input>
+              <span class="value">
+                <gr-autocomplete id="defaultBranchNameInput"> </gr-autocomplete>
+              </span>
             </section>
             <section>
               <span class="title"> Rights inherit from </span>
@@ -118,7 +118,7 @@
 
     element.repoOwner = 'test';
     element.repoOwnerId = 'testId' as GroupId;
-    element.defaultBranch = 'main' as BranchName;
+    element.selectedDefaultBranch = 'main' as BranchName;
 
     const repoNameInput = queryAndAssert<HTMLInputElement>(
       element,
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 cffde7a..7d6d17d 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
@@ -31,7 +31,7 @@
 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 {grFormStyles} 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';
@@ -135,7 +135,7 @@
   static override get styles() {
     return [
       fontStyles,
-      formStyles,
+      grFormStyles,
       sharedStyles,
       subpageStyles,
       tableStyles,
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 223a700..8ee7669 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -19,7 +19,7 @@
 import {convertToString} from '../../../utils/string-util';
 import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
 import {fontStyles} from '../../../styles/gr-font-styles';
-import {formStyles} from '../../../styles/gr-form-styles';
+import {grFormStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {subpageStyles} from '../../../styles/gr-subpage-styles';
 import {LitElement, PropertyValues, css, html} from 'lit';
@@ -103,7 +103,7 @@
   static override get styles() {
     return [
       fontStyles,
-      formStyles,
+      grFormStyles,
       sharedStyles,
       subpageStyles,
       css`
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 7f03d1d..933fc96 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -36,7 +36,7 @@
 import {fire} 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 {grFormStyles} from '../../../styles/gr-form-styles';
 import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
 import {when} from 'lit/directives/when.js';
 import {
@@ -127,8 +127,9 @@
   }
 
   override willUpdate(changedProperties: PropertyValues<GrPermission>): void {
-    if (changedProperties.has('editing')) {
-      this.handleEditingChanged(changedProperties.get('editing'));
+    const oldEditing = changedProperties.get('editing');
+    if (oldEditing !== null && oldEditing !== undefined) {
+      this.handleEditingChanged(oldEditing);
     }
     if (
       changedProperties.has('permission') ||
@@ -141,65 +142,67 @@
     }
   }
 
-  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;
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      sharedStyles,
+      paperStyles,
+      grFormStyles,
+      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) {
@@ -350,8 +353,16 @@
     if (!this.permission) {
       return;
     }
-    this.permission.value.modified = true;
-    this.permission.value.exclusive = (e.target as HTMLInputElement).checked;
+    // Update entire permission object to trigger a re-render since permission
+    // is marked as @property
+    this.permission = {
+      ...this.permission,
+      value: {
+        ...this.permission.value,
+        modified: true,
+        exclusive: (e.target as HTMLInputElement).checked,
+      },
+    };
     // Allows overall access page to know a change has been made.
     fire(this, 'access-modified', {});
   }
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 5afaa9d..cccc55a 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
@@ -10,7 +10,7 @@
   PluginConfigOptionsChangedEventDetail,
   ArrayPluginOption,
 } from '../gr-repo-plugin-config/gr-repo-plugin-config-types';
-import {formStyles} from '../../../styles/gr-form-styles';
+import {grFormStyles} 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';
@@ -42,7 +42,7 @@
   static override get styles() {
     return [
       sharedStyles,
-      formStyles,
+      grFormStyles,
       css`
         .wrapper {
           width: 30em;
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 2809d6e..54dc4ee 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
@@ -81,6 +81,9 @@
   @state() ownerOf?: GitRef[];
 
   // private but used in test
+  @state() isOwner = false;
+
+  // private but used in test
   @state() capabilities?: CapabilityInfoMap;
 
   // private but used in test
@@ -234,9 +237,7 @@
             >
             <gr-button
               id="saveBtn"
-              class=${this.ownerOf && this.ownerOf.length === 0
-                ? 'invisible'
-                : ''}
+              class=${this.isOwner ? '' : 'invisible'}
               primary
               ?disabled=${!this.modified}
               @click=${this.handleSave}
@@ -344,6 +345,7 @@
         this.weblinks = res.config_web_links || [];
         this.canUpload = res.can_upload;
         this.ownerOf = res.owner_of || [];
+        this.isOwner = res.is_owner ?? false;
         return toSortedPermissionsArray(this.local);
       });
 
@@ -708,7 +710,7 @@
   // private but used in test
   computeMainClass() {
     const classList = [];
-    if ((this.ownerOf && this.ownerOf.length > 0) || this.canUpload) {
+    if (this.isOwner || this.canUpload) {
       classList.push('admin');
     }
     if (this.editing) {
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
index 467857d..ead27fd 100644
--- 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
@@ -15,12 +15,7 @@
   queryAndAssert,
   stubRestApi,
 } from '../../../test/test-utils';
-import {
-  ChangeInfo,
-  GitRef,
-  RepoName,
-  UrlEncodedRepoName,
-} from '../../../types/common';
+import {ChangeInfo, RepoName, UrlEncodedRepoName} from '../../../types/common';
 import {PermissionAction} from '../../../constants/constants';
 import {AutocompleteCommitEvent, PageErrorEvent} from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
@@ -257,13 +252,13 @@
   });
 
   test('computeMainClass', () => {
-    element.ownerOf = ['refs/*'] as GitRef[];
+    element.isOwner = true;
     element.editing = false;
     element.canUpload = false;
     assert.equal(element.computeMainClass(), 'admin');
     element.editing = true;
     assert.equal(element.computeMainClass(), 'admin editing');
-    element.ownerOf = [];
+    element.isOwner = false;
     element.editing = false;
     assert.equal(element.computeMainClass(), '');
     element.editing = true;
@@ -514,13 +509,13 @@
     });
 
     test('button visibility for ref owner', async () => {
-      element.ownerOf = ['refs/for/*'] as GitRef[];
+      element.isOwner = true;
       await element.updateComplete;
       testEditSaveCancelBtns(true, false);
     });
 
     test('button visibility for ref owner and upload', async () => {
-      element.ownerOf = ['refs/for/*'] as GitRef[];
+      element.isOwner = true;
       element.canUpload = true;
       await element.updateComplete;
       testEditSaveCancelBtns(true, false);
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 553de0e..7edfe2b 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
@@ -25,7 +25,7 @@
 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 {grFormStyles} 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';
@@ -97,7 +97,7 @@
   static override get styles() {
     return [
       fontStyles,
-      formStyles,
+      grFormStyles,
       subpageStyles,
       sharedStyles,
       modalStyles,
@@ -208,7 +208,7 @@
       return;
 
     return html`
-      <h3 class="heading-3">${this.repoConfig?.actions['gc']?.label}</h3>
+      <h2 class="heading-2">${this.repoConfig?.actions['gc']?.label}</h2>
       <gr-button
         title=${this.repoConfig?.actions['gc']?.title || ''}
         ?loading=${this.runningGC}
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 bff56bdd..e099184 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
@@ -11,7 +11,10 @@
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {LitElement, css, html, PropertyValues} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
-import {createDashboardUrl} from '../../../models/views/dashboard';
+import {
+  DashboardType,
+  createDashboardUrl,
+} from '../../../models/views/dashboard';
 
 interface DashboardRef {
   section: string;
@@ -156,7 +159,7 @@
   _getUrl(project?: RepoName, dashboard?: DashboardId) {
     if (!project || !dashboard) return '';
 
-    return createDashboardUrl({project, dashboard});
+    return createDashboardUrl({project, type: DashboardType.REPO, dashboard});
   }
 
   _computeLoadingClass(loading: boolean) {
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 70403fd..f20137a 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
@@ -25,7 +25,7 @@
 import {firePageError} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
-import {formStyles} from '../../../styles/gr-form-styles';
+import {grFormStyles} from '../../../styles/gr-form-styles';
 import {tableStyles} from '../../../styles/gr-table-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html, nothing} from 'lit';
@@ -84,7 +84,7 @@
 
   static override get styles() {
     return [
-      formStyles,
+      grFormStyles,
       tableStyles,
       sharedStyles,
       modalStyles,
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 fabf93d..306d765 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
@@ -5,7 +5,7 @@
  */
 import '@polymer/iron-input/iron-input';
 import '@polymer/paper-toggle-button/paper-toggle-button';
-import {formStyles} from '../../../styles/gr-form-styles';
+import {grFormStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {subpageStyles} from '../../../styles/gr-subpage-styles';
 import '../../shared/gr-select/gr-select';
@@ -59,7 +59,7 @@
   static override get styles() {
     return [
       sharedStyles,
-      formStyles,
+      grFormStyles,
       paperStyles,
       subpageStyles,
       css`
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 4bbd533..7117944 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -31,7 +31,7 @@
 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 {grFormStyles} from '../../../styles/gr-form-styles';
 import {subpageStyles} from '../../../styles/gr-subpage-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {BindValueChangeEvent} from '../../../types/events';
@@ -140,7 +140,7 @@
   static override get styles() {
     return [
       fontStyles,
-      formStyles,
+      grFormStyles,
       subpageStyles,
       sharedStyles,
       css`
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 4e41dfe..a45fa33 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
@@ -9,7 +9,7 @@
 import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 import {AccessPermissionId} from '../../../utils/access-util';
 import {fire} from '../../../utils/event-util';
-import {formStyles} from '../../../styles/gr-form-styles';
+import {grFormStyles} 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';
@@ -17,6 +17,7 @@
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {EditablePermissionRuleInfo} from '../gr-repo-access/gr-repo-access-interfaces';
 import {PermissionAction} from '../../../constants/constants';
+import {formStyles} from '../../../styles/form-styles';
 
 const PRIORITY_OPTIONS = [PermissionAction.BATCH, PermissionAction.INTERACTIVE];
 
@@ -127,6 +128,7 @@
 
   static override get styles() {
     return [
+      grFormStyles,
       formStyles,
       sharedStyles,
       css`
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
index 8a5bf47..c65371b 100644
--- 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
@@ -37,6 +37,7 @@
 import {ProgressStatus} from '../../../constants/constants';
 import {StandardLabels} from '../../../utils/label-util';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {ReviewResult} from '../../../types/common';
 
 const change1: ChangeInfo = {
   ...createChange(),
@@ -208,7 +209,7 @@
     );
     stubRestApi('saveChangeReview').callsFake(
       (_changeNum, _patchNum, _review, errFn) =>
-        Promise.resolve(new Response()).then(res => {
+        Promise.resolve(undefined).then(res => {
           errFn && errFn();
           return res;
         })
@@ -359,7 +360,7 @@
     );
     await selectChange(change);
     await element.updateComplete;
-    const saveChangeReview = mockPromise<Response>();
+    const saveChangeReview = mockPromise<ReviewResult>();
     stubRestApi('saveChangeReview').returns(saveChangeReview);
 
     queryAndAssert<GrButton>(element, '#voteFlowButton').click();
@@ -411,7 +412,7 @@
       ProgressStatus.RUNNING
     );
 
-    saveChangeReview.resolve({...new Response(), status: 200});
+    saveChangeReview.resolve({});
     await waitUntil(
       () =>
         element.progressByChange.get(1 as NumericChangeId) ===
@@ -445,7 +446,7 @@
 
       stubRestApi('saveChangeReview').callsFake(
         (_changeNum, _patchNum, _review, errFn) =>
-          Promise.resolve(new Response()).then(res => {
+          Promise.resolve({}).then(res => {
             errFn && errFn();
             return res;
           })
@@ -503,7 +504,7 @@
 
       stubRestApi('saveChangeReview').callsFake(
         (_changeNum, _patchNum, _review, errFn) =>
-          Promise.resolve(new Response()).then(res => {
+          Promise.resolve(undefined).then(res => {
             errFn && errFn();
             return res;
           })
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
index 13807c8..3e92658 100644
--- 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
@@ -200,7 +200,7 @@
     const requirements = getRequirements(this.change).filter(
       sr => sr.name === labelName
     );
-    // TODO(milutin): Remove this after migration from legacy requirements.
+    // It can be removed in future when is_legacy is not used on any host.
     if (requirements.length > 1) {
       return requirements.filter(sr => !sr.is_legacy);
     } else {
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 19207bc..fc5760b 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
@@ -5,7 +5,6 @@
  */
 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-icon/gr-icon';
 import '../../shared/gr-limited-text/gr-limited-text';
@@ -19,7 +18,6 @@
 import {getDisplayName} from '../../../utils/display-name-util';
 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 {
   ChangeInfo,
@@ -44,6 +42,8 @@
 import {createChangeUrl} from '../../../models/views/change';
 import {userModelToken} from '../../../models/user/user-model';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {configModelToken} from '../../../models/config/config-model';
+import {formStyles} from '../../../styles/form-styles';
 
 enum ChangeSize {
   XS = 10,
@@ -58,7 +58,6 @@
   APPROVED = 'APPROVED',
   POSITIVE = 'POSITIVE',
   NEUTRAL = 'NEUTRAL',
-  UNRESOLVED_COMMENTS = 'UNRESOLVED_COMMENTS',
   NEGATIVE = 'NEGATIVE',
   REJECTED = 'REJECTED',
 }
@@ -74,9 +73,16 @@
 
 @customElement('gr-change-list-item')
 export class GrChangeListItem extends LitElement {
-  /** The logged-in user's account, or null if no user is logged in. */
+  /** The logged-in user's account, or undefined if no user is logged in. */
   @property({type: Object})
-  account: AccountInfo | null = null;
+  loggedInUser?: AccountInfo;
+
+  /**
+   * When the list is part of the dashboard, the user for which the dashboard is
+   * generated.
+   */
+  @property({type: String})
+  dashboardUser?: string;
 
   @property({type: Array})
   visibleChangeTableColumns?: string[];
@@ -87,9 +93,6 @@
   @property({type: Object})
   change?: ChangeInfo;
 
-  @property({type: Object})
-  config?: ServerInfo;
-
   /** Name of the section in the change-list. Used for reporting. */
   @property({type: String})
   sectionName?: string;
@@ -113,6 +116,9 @@
   // private but used in tests
   @property({type: Boolean, reflect: true}) checked = false;
 
+  @state()
+  config?: ServerInfo;
+
   @state() private dynamicCellEndpoints?: string[];
 
   private readonly reporting = getAppContext().reportingService;
@@ -121,6 +127,8 @@
 
   private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
 
+  private readonly getConfigModel = resolve(this, configModelToken);
+
   private readonly getNavigation = resolve(this, navigationToken);
 
   private readonly getUserModel = resolve(this, userModelToken);
@@ -141,6 +149,13 @@
       () => this.getUserModel().loggedIn$,
       isLoggedIn => (this.isLoggedIn = isLoggedIn)
     );
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.config = config;
+      }
+    );
   }
 
   override connectedCallback() {
@@ -185,6 +200,7 @@
   static override get styles() {
     return [
       changeListStyles,
+      formStyles,
       sharedStyles,
       submitRequirementsStyles,
       css`
@@ -202,10 +218,6 @@
         .container {
           position: relative;
         }
-        .strikethrough {
-          color: var(--deemphasized-text-color);
-          text-decoration: line-through;
-        }
         .content {
           overflow: hidden;
           position: absolute;
@@ -220,7 +232,6 @@
           white-space: nowrap;
           width: 100%;
         }
-        .comments,
         .reviewers,
         .requirements {
           white-space: nowrap;
@@ -232,17 +243,6 @@
           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;
@@ -281,11 +281,18 @@
           cursor: pointer;
           text-decoration: none;
         }
-        a:hover {
+        /* The subject cell needs a separate rule for these reasons:
+           1. :hover does not propagate to absolutely positioned children.
+           2. .strikethrough for abandoned changes must be respected.
+           3. We don't want the "spacer" and the &nbsp; to be underlined.
+        */
+        .cell:not(.subject) a:hover,
+        .cell.subject a:hover .content:not(.strikethrough) {
           text-decoration: underline;
         }
-        .subject:hover .content {
-          text-decoration: underline;
+        .strikethrough {
+          color: var(--deemphasized-text-color);
+          text-decoration: line-through;
         }
         .comma,
         .placeholder {
@@ -325,8 +332,7 @@
       <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.renderCellOwner()} ${this.renderCellReviewers()}
       ${this.renderCellRepo()} ${this.renderCellBranch()}
       ${this.renderCellUpdated()} ${this.renderCellSubmitted()}
       ${this.renderCellWaiting()} ${this.renderCellSize()}
@@ -381,13 +387,7 @@
   }
 
   private renderCellSubject(changeUrl: string) {
-    if (
-      this.computeIsColumnHidden(
-        ColumnNames.SUBJECT,
-        this.visibleChangeTableColumns
-      )
-    )
-      return;
+    if (this.computeIsColumnHidden(ColumnNames.SUBJECT)) return;
 
     return html`
       <td class="cell subject">
@@ -413,39 +413,8 @@
     `;
   }
 
-  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;
+    if (this.computeIsColumnHidden(ColumnNames.OWNER)) return;
 
     return html`
       <td class="cell owner">
@@ -460,13 +429,7 @@
   }
 
   private renderCellReviewers() {
-    if (
-      this.computeIsColumnHidden(
-        ColumnNames.REVIEWERS,
-        this.visibleChangeTableColumns
-      )
-    )
-      return;
+    if (this.computeIsColumnHidden(ColumnNames.REVIEWERS)) return;
 
     return html`
       <td class="cell reviewers">
@@ -500,29 +463,8 @@
     `;
   }
 
-  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
-      )
-    ) {
+    if (this.computeIsColumnHidden(ColumnNames.REPO)) {
       return;
     }
 
@@ -538,13 +480,7 @@
   }
 
   private renderCellBranch() {
-    if (
-      this.computeIsColumnHidden(
-        ColumnNames.BRANCH,
-        this.visibleChangeTableColumns
-      )
-    )
-      return;
+    if (this.computeIsColumnHidden(ColumnNames.BRANCH)) return;
 
     return html`
       <td class="cell branch">
@@ -569,8 +505,7 @@
   }
 
   private renderCellUpdated() {
-    if (this.computeIsColumnHidden('Updated', this.visibleChangeTableColumns))
-      return;
+    if (this.computeIsColumnHidden(ColumnNames.UPDATED)) return;
 
     return html`
       <td class="cell updated">
@@ -583,8 +518,7 @@
   }
 
   private renderCellSubmitted() {
-    if (this.computeIsColumnHidden('Submitted', this.visibleChangeTableColumns))
-      return;
+    if (this.computeIsColumnHidden('Submitted')) return;
 
     return html`
       <td class="cell submitted">
@@ -597,8 +531,7 @@
   }
 
   private renderCellWaiting() {
-    if (this.computeIsColumnHidden(WAITING, this.visibleChangeTableColumns))
-      return;
+    if (this.computeIsColumnHidden(WAITING)) return;
 
     return html`
       <td class="cell waiting">
@@ -613,8 +546,7 @@
   }
 
   private renderCellSize() {
-    if (this.computeIsColumnHidden('Size', this.visibleChangeTableColumns))
-      return;
+    if (this.computeIsColumnHidden(ColumnNames.SIZE)) return;
 
     return html`
       <td class="cell size">
@@ -635,16 +567,10 @@
   }
 
   private renderCellRequirements() {
-    if (
-      this.computeIsColumnHidden(
-        ColumnNames.STATUS2,
-        this.visibleChangeTableColumns
-      )
-    )
-      return;
+    if (this.computeIsColumnHidden(ColumnNames.STATUS)) return;
 
     return html`
-      <td class="cell requirements">
+      <td class="cell status requirements">
         <gr-change-list-column-requirements-summary .change=${this.change}>
         </gr-change-list-column-requirements-summary>
       </td>
@@ -684,11 +610,6 @@
     }
   };
 
-  private changeStatuses() {
-    if (!this.change) return [];
-    return changeStatuses(this.change);
-  }
-
   private computeChangeURL() {
     if (!this.change) return '';
     return createChangeUrl({change: this.change, usp: this.usp});
@@ -754,9 +675,9 @@
         !isServiceUser(r)
     );
     reviewers.sort((r1, r2) => {
-      if (this.account) {
-        if (isSelf(r1, this.account)) return -1;
-        if (isSelf(r2, this.account)) return 1;
+      if (this.loggedInUser) {
+        if (isSelf(r1, this.loggedInUser)) return -1;
+        if (isSelf(r2, this.loggedInUser)) return 1;
       }
       if (this.hasAttention(r1) && !this.hasAttention(r2)) return -1;
       if (this.hasAttention(r2) && !this.hasAttention(r1)) return 1;
@@ -784,11 +705,6 @@
       .join(', ');
   }
 
-  private computeComments(unresolved_comment_count?: number) {
-    if (!unresolved_comment_count || unresolved_comment_count < 1) return '';
-    return `${unresolved_comment_count} unresolved`;
-  }
-
   /**
    * TShirt sizing is based on the following paper:
    * http://dirkriehle.com/wp-content/uploads/2008/09/hicss-42-csdistr-final-web.pdf
@@ -815,19 +731,23 @@
   }
 
   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;
+    // TODO: dashboardUser comes from DashboardViewState and can be an
+    // Email Address. In this case the attention_set lookup will return
+    // undefined.
+    const userId =
+      this.dashboardUser === 'self'
+        ? this.loggedInUser?._account_id
+        : this.dashboardUser;
+    if (!userId || !this.change?.attention_set) return undefined;
+    return this.change?.attention_set[userId]?.last_update;
   }
 
-  private computeIsColumnHidden(
-    columnToCheck?: string,
-    columnsToDisplay?: string[]
-  ) {
-    if (!columnsToDisplay || !columnToCheck) {
-      return false;
-    }
-    return !columnsToDisplay.includes(columnToCheck);
+  private computeIsColumnHidden(columnToCheck?: string) {
+    if (!columnToCheck) return false;
+    const columnsToDisplay = this.visibleChangeTableColumns ?? [];
+    return (
+      columnsToDisplay.length > 0 && !columnsToDisplay.includes(columnToCheck)
+    );
   }
 
   private formatDate(date: Timestamp | undefined): string | undefined {
@@ -839,7 +759,7 @@
     // 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.
 
-    const selfId = (this.account && this.account._account_id) || -1;
+    const selfId = (this.loggedInUser && this.loggedInUser._account_id) || -1;
     const ownerId =
       (this.change && this.change.owner && this.change.owner._account_id) || -1;
 
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 5e31cc8..4f5a888 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
@@ -82,15 +82,13 @@
   test('no hidden columns', async () => {
     element.visibleChangeTableColumns = [
       ColumnNames.SUBJECT,
-      ColumnNames.STATUS,
       ColumnNames.OWNER,
       ColumnNames.REVIEWERS,
-      ColumnNames.COMMENTS,
       ColumnNames.REPO,
       ColumnNames.BRANCH,
       ColumnNames.UPDATED,
       ColumnNames.SIZE,
-      ColumnNames.STATUS2,
+      ColumnNames.STATUS,
     ];
 
     await element.updateComplete;
@@ -214,14 +212,12 @@
   test('repo column hidden', async () => {
     element.visibleChangeTableColumns = [
       ColumnNames.SUBJECT,
-      ColumnNames.STATUS,
       ColumnNames.OWNER,
       ColumnNames.REVIEWERS,
-      ColumnNames.COMMENTS,
       ColumnNames.BRANCH,
       ColumnNames.UPDATED,
       ColumnNames.SIZE,
-      ColumnNames.STATUS2,
+      ColumnNames.STATUS,
     ];
 
     await element.updateComplete;
@@ -243,7 +239,9 @@
     attSetIds: number[],
     expected: number[]
   ) {
-    element.account = userId ? {_account_id: userId as AccountId} : null;
+    element.loggedInUser = userId
+      ? {_account_id: userId as AccountId}
+      : undefined;
     element.change = {
       ...change,
       owner: {
@@ -384,7 +382,7 @@
       registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
     });
     element.showNumber = true;
-    element.account = createAccountWithId(1);
+    element.loggedInUser = createAccountWithId(1);
     element.config = createServerInfo();
     element.change = change;
     await element.updateComplete;
@@ -408,14 +406,12 @@
             <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>
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
index 61b276e..91770ca 100644
--- 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
@@ -9,7 +9,7 @@
 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 {ChangeInfo, 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';
@@ -24,6 +24,7 @@
 import {userModelToken} from '../../../models/user/user-model';
 import {subscribe} from '../../lit/subscription-controller';
 import {classMap} from 'lit/directives/class-map.js';
+import {formStyles} from '../../../styles/form-styles';
 
 const NUMBER_FIXED_COLUMNS = 4;
 const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
@@ -67,9 +68,6 @@
   @property({type: Object})
   changeSection!: ChangeListSection;
 
-  @property({type: Object})
-  config?: ServerInfo;
-
   @property({type: Boolean})
   isCursorMoving = false;
 
@@ -78,7 +76,14 @@
    * in.
    */
   @property({type: Object})
-  account: AccountInfo | undefined = undefined;
+  loggedInUser?: AccountInfo;
+
+  /**
+   * When the list is part of the dashboard, the user for which the dashboard is
+   * generated.
+   */
+  @property({type: String})
+  dashboardUser?: string;
 
   @property({type: String})
   usp?: string;
@@ -110,6 +115,7 @@
     return [
       changeListStyles,
       fontStyles,
+      formStyles,
       sharedStyles,
       css`
         :host {
@@ -322,10 +328,10 @@
     return html`
       <gr-change-list-item
         tabindex="0"
-        .account=${this.account}
+        .loggedInUser=${this.loggedInUser}
+        .dashboardUser=${this.dashboardUser}
         .selected=${selected}
         .change=${change}
-        .config=${this.config}
         .sectionName=${this.changeSection.name}
         .visibleChangeTableColumns=${columns}
         .showNumber=${this.showNumber}
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
index 63552c7..694d953 100644
--- 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
@@ -56,7 +56,7 @@
     };
     element = await fixture<GrChangeListSection>(
       html`<gr-change-list-section
-        .account=${createAccountDetailWithId(1)}
+        .loggedInUser=${createAccountDetailWithId(1)}
         .config=${createServerInfo()}
         .visibleChangeTableColumns=${Object.values(ColumnNames)}
         .changeSection=${changeSection}
@@ -75,7 +75,7 @@
         <input class="selection-checkbox" type="checkbox"/>
       </td>
       #
-              SubjectStatusOwnerReviewersCommentsRepoBranchUpdatedSize Status
+              SubjectOwnerReviewersRepoBranchUpdatedSizeStatus
       <gr-change-list-item
         aria-label="Test subject, section: test"
         role="button"
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 96b01e1..dbca42f 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
@@ -11,7 +11,6 @@
   AccountId,
   ChangeInfo,
   EmailAddress,
-  PreferencesInput,
   RepoName,
 } from '../../../types/common';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
@@ -44,9 +43,6 @@
   @state() loggedIn = false;
 
   // private but used in test
-  @state() preferences?: PreferencesInput;
-
-  // private but used in test
   @state() changesPerPage?: number;
 
   // private but used in test
@@ -127,11 +123,6 @@
       () => this.getUserModel().preferenceChangesPerPage$,
       x => (this.changesPerPage = x)
     );
-    subscribe(
-      this,
-      () => this.getUserModel().preferences$,
-      x => (this.preferences = x)
-    );
   }
 
   static override get styles() {
@@ -186,9 +177,8 @@
       <div ?hidden=${this.loading}>
         ${this.renderRepoHeader()} ${this.renderUserHeader()}
         <gr-change-list
-          .account=${this.account}
+          .loggedInUser=${this.account}
           .changes=${this.changes}
-          .preferences=${this.preferences}
           @toggle-star=${(e: CustomEvent<ChangeStarToggleStarDetail>) => {
             this.handleToggleStar(e);
           }}
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 117abd6..9495bea 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
@@ -8,7 +8,6 @@
 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 {getAppContext} from '../../../services/app-context';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {
@@ -19,7 +18,10 @@
 } from '../../../types/common';
 import {fire, fireReload} from '../../../utils/event-util';
 import {ColumnNames, ScrollMode} from '../../../constants/constants';
-import {getRequirements} from '../../../utils/label-util';
+import {
+  getRequirements,
+  orderSubmitRequirements,
+} 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';
@@ -30,11 +32,16 @@
 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';
+import {subscribe} from '../../lit/subscription-controller';
+import {
+  changeTablePrefs,
+  userModelToken,
+} from '../../../models/user/user-model';
+import {configModelToken} from '../../../models/config/config-model';
 
 export interface ChangeListSection {
   countLabel?: string;
@@ -81,7 +88,14 @@
    * in.
    */
   @property({type: Object})
-  account: AccountInfo | undefined = undefined;
+  loggedInUser?: AccountInfo;
+
+  /**
+   * When the list is part of the dashboard, the user for which the dashboard is
+   * generated.
+   */
+  @property({type: String})
+  dashboardUser?: string;
 
   @property({type: Array})
   changes?: ChangeInfo[];
@@ -112,7 +126,7 @@
   @property({type: Array})
   visibleChangeTableColumns?: string[];
 
-  @property({type: Object})
+  @state()
   preferences?: PreferencesInput;
 
   @property({type: Boolean})
@@ -121,18 +135,16 @@
   // private but used in test
   @state() config?: ServerInfo;
 
-  private readonly flagsService = getAppContext().flagsService;
-
-  private readonly restApiService = getAppContext().restApiService;
-
-  private readonly reporting = getAppContext().reportingService;
-
   private readonly shortcuts = new ShortcutController(this);
 
+  private readonly getConfigModel = resolve(this, configModelToken);
+
   private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly getUserModel = resolve(this, userModelToken);
+
   private cursor = new GrCursorManager();
 
   constructor() {
@@ -158,13 +170,22 @@
       this.toggleCheckbox()
     );
     this.shortcuts.addGlobal({key: Key.ENTER}, () => this.openChange());
+    subscribe(
+      this,
+      () => this.getUserModel().preferences$,
+      x => (this.preferences = x)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.config = config;
+      }
+    );
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this.restApiService.getConfig().then(config => {
-      this.config = config;
-    });
     this.getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
@@ -248,8 +269,8 @@
         .labelNames=${labelNames}
         .dynamicHeaderEndpoints=${this.dynamicHeaderEndpoints}
         .isCursorMoving=${this.isCursorMoving}
-        .config=${this.config}
-        .account=${this.account}
+        .loggedInUser=${this.loggedInUser}
+        .dashboardUser=${this.dashboardUser}
         .selectedIndex=${computeRelativeIndex(
           this.selectedIndex,
           sectionIndex,
@@ -276,7 +297,7 @@
 
   override willUpdate(changedProperties: PropertyValues) {
     if (
-      changedProperties.has('account') ||
+      changedProperties.has('loggedInUser') ||
       changedProperties.has('preferences') ||
       changedProperties.has('config') ||
       changedProperties.has('sections')
@@ -324,46 +345,17 @@
 
     this.changeTableColumns = Object.values(ColumnNames);
     this.showNumber = false;
-    this.visibleChangeTableColumns = this.changeTableColumns.filter(col =>
-      this.isColumnEnabled(col, this.config)
-    );
-    if (this.account && this.preferences) {
+    this.visibleChangeTableColumns = Object.values(ColumnNames);
+    if (this.loggedInUser && 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));
-      }
+      const prefColumns = changeTablePrefs(this.preferences);
+      // This is for sorting `prefColumns` as in `ColumnNames`:
+      this.visibleChangeTableColumns = Object.values(ColumnNames).filter(col =>
+        prefColumns.includes(col)
+      );
     }
   }
 
-  /**
-   * Is the column disabled by a server config or experiment?
-   */
-  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 === 'Comments')
-      return this.flagsService.isEnabled('comments-column');
-    if (column === 'Status') return false;
-    if (column === ColumnNames.STATUS2) return true;
-    return true;
-  }
-
   // private but used in test
   computeLabelNames(sections: ChangeListSection[]) {
     if (!sections) return [];
@@ -372,7 +364,7 @@
     }
     const changes = sections.map(section => section.results).flat();
     const labels = (changes ?? [])
-      .map(change => getRequirements(change))
+      .map(change => orderSubmitRequirements(getRequirements(change)))
       .flat()
       .map(requirement => requirement.name)
       .filter(unique);
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
index e201ab4..c53f813 100644
--- 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
@@ -17,7 +17,6 @@
 } from '../../../test/test-utils';
 import {Key} from '../../../utils/dom-util';
 import {
-  ColumnNames,
   createDefaultPreferences,
   TimeFormat,
 } from '../../../constants/constants';
@@ -51,7 +50,7 @@
       time_format: TimeFormat.HHMM_12,
       change_table: [],
     };
-    element.account = {_account_id: 1001 as AccountId};
+    element.loggedInUser = {_account_id: 1001 as AccountId};
     element.config = createServerInfo();
     element.sections = [
       {
@@ -106,7 +105,7 @@
   });
 
   test('show change number disabled when not logged in', async () => {
-    element.account = undefined;
+    element.loggedInUser = undefined;
     element.preferences = undefined;
     element.config = createServerInfo();
     await element.updateComplete;
@@ -120,7 +119,7 @@
       time_format: TimeFormat.HHMM_12,
       change_table: [],
     };
-    element.account = {_account_id: 1001 as AccountId};
+    element.loggedInUser = {_account_id: 1001 as AccountId};
     element.config = createServerInfo();
     await element.updateComplete;
 
@@ -133,7 +132,7 @@
       time_format: TimeFormat.HHMM_12,
       change_table: [],
     };
-    element.account = {_account_id: 1001 as AccountId};
+    element.loggedInUser = {_account_id: 1001 as AccountId};
     element.config = createServerInfo();
     await element.updateComplete;
 
@@ -415,7 +414,7 @@
       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.loggedInUser = {_account_id: 1001 as AccountId};
       element.preferences = {
         legacycid_in_change_table: true,
         time_format: TimeFormat.HHMM_12,
@@ -447,7 +446,7 @@
       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.loggedInUser = {_account_id: 1001 as AccountId};
       element.preferences = {
         legacycid_in_change_table: true,
         time_format: TimeFormat.HHMM_12,
@@ -456,12 +455,10 @@
           'Status',
           'Owner',
           'Reviewers',
-          'Comments',
           'Repo',
           'Branch',
           'Updated',
           'Size',
-          ColumnNames.STATUS2,
         ],
       };
       element.config = createServerInfo();
@@ -486,7 +483,7 @@
       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.loggedInUser = {_account_id: 1001 as AccountId};
       element.preferences = {
         legacycid_in_change_table: true,
         time_format: TimeFormat.HHMM_12,
@@ -495,11 +492,9 @@
           'Status',
           'Owner',
           'Reviewers',
-          'Comments',
           'Branch',
           'Updated',
           'Size',
-          ColumnNames.STATUS2,
         ],
       };
       element.config = createServerInfo();
@@ -531,13 +526,9 @@
     });
   });
 
-  test('obsolete column in preferences not visible', () => {
-    assert.isTrue(element.isColumnEnabled('Subject'));
-  });
-
   test('loggedIn and showNumber', async () => {
     element.sections = [{results: [{...createChange()}], name: 'a'}];
-    element.account = {_account_id: 1001 as AccountId};
+    element.loggedInUser = {_account_id: 1001 as AccountId};
     element.preferences = {
       legacycid_in_change_table: false, // sets showNumber false
       time_format: TimeFormat.HHMM_12,
@@ -546,11 +537,9 @@
         'Status',
         'Owner',
         'Reviewers',
-        'Comments',
         'Branch',
         'Updated',
         'Size',
-        ColumnNames.STATUS2,
       ],
     };
     element.config = createServerInfo();
@@ -586,7 +575,7 @@
 
   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.loggedInUser = {_account_id: 1001 as AccountId};
     element.preferences = {
       legacycid_in_change_table: true,
       time_format: TimeFormat.HHMM_12,
@@ -596,26 +585,4 @@
 
     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 d9be9ca..e4c413f 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
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
 import {fire} from '../../../utils/event-util';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
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 a870835..ace2fb5 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
@@ -43,6 +43,7 @@
 import {Shortcut} from '../../../services/shortcuts/shortcuts-config';
 import {ShortcutController} from '../../lit/shortcut-controller';
 import {
+  DashboardType,
   dashboardViewModelToken,
   DashboardViewState,
 } from '../../../models/views/dashboard';
@@ -79,7 +80,7 @@
   protected confirmDeleteModal?: HTMLDialogElement;
 
   @property({type: Object})
-  account?: AccountDetailInfo;
+  loggedInUser?: AccountDetailInfo;
 
   @property({type: Object})
   preferences?: PreferencesInput;
@@ -126,7 +127,14 @@
     subscribe(
       this,
       () => this.getUserModel().account$,
-      x => (this.account = x)
+      x => (this.loggedInUser = x)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().preferences$,
+      prefs => {
+        this.preferences = prefs ?? {};
+      }
     );
     subscribe(
       this,
@@ -154,7 +162,6 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    this.loadPreferences();
     document.addEventListener(
       'visibilitychange',
       this.visibilityChangeListener
@@ -280,7 +287,8 @@
         ${this.renderUserHeader()}
         <h1 class="assistive-tech-only">Dashboard</h1>
         <gr-change-list
-          .account=${this.account}
+          .loggedInUser=${this.loggedInUser}
+          .dashboardUser=${this.viewState?.user}
           .preferences=${this.preferences}
           .sections=${this.results}
           .usp=${'dashboard'}
@@ -325,18 +333,6 @@
     `;
   }
 
-  private loadPreferences() {
-    return this.restApiService.getLoggedIn().then(loggedIn => {
-      if (loggedIn) {
-        this.restApiService.getPreferences().then(preferences => {
-          this.preferences = preferences;
-        });
-      } else {
-        this.preferences = {};
-      }
-    });
-  }
-
   // private but used in test
   getRepositoryDashboard(
     repo: RepoName,
@@ -391,26 +387,30 @@
     this.firstTimeLoad = false;
 
     this.loading = true;
-    const {project, dashboard, title, user, sections} = this.viewState;
+    const {project, type, 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.
+    // Checking `this.loggedInUser` 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';
+    const isLoggedInUserDashboard =
+      !project && !!this.loggedInUser && user === 'self';
     return dashboardPromise
       .then(res => {
         if (res && res.title) {
           fireTitleChange(res.title);
         }
-        return this.fetchDashboardChanges(res, checkForNewUser);
+        return this.fetchDashboardChanges(res, isLoggedInUserDashboard);
       })
       .then(() => {
         this.maybeShowDraftsBanner();
-        this.reporting.dashboardDisplayed();
+        // Only report the metric for the default personal dashboard.
+        if (type === DashboardType.USER && isLoggedInUserDashboard) {
+          this.reporting.dashboardDisplayed();
+        }
       })
       .catch(err => {
         fireTitleChange(title || this.computeTitle(user));
@@ -429,7 +429,7 @@
    */
   fetchDashboardChanges(
     res: UserDashboard | undefined,
-    checkForNewUser: boolean
+    isLoggedInUserDashboard: boolean
   ): Promise<void> {
     if (!res) {
       return Promise.resolve();
@@ -448,7 +448,8 @@
           : section.query
       );
 
-      if (checkForNewUser) {
+      if (isLoggedInUserDashboard) {
+        // The query to check if the user created any changes yet.
         queries.push('owner:self limit:1');
       }
     }
@@ -459,8 +460,9 @@
         if (!changes) {
           throw new Error('getChanges returns undefined');
         }
-        if (checkForNewUser) {
-          // Last set of results is not meant for dashboard display.
+        if (isLoggedInUserDashboard) {
+          // Last query ('owner:self limit:1') is only for evaluation if
+          // the user is "New" ie. haven't created any changes yet.
           const lastResultSet = changes.pop();
           this.showNewUserHelp = lastResultSet!.length === 0;
         }
@@ -486,19 +488,24 @@
 
   /**
    * Usually we really want to stick to the sorting that the backend provides,
-   * but for the "Your Turn" section it is important to put the changes at the
+   * but for the "Your turn" section it is important to put the changes at the
    * 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.
    */
   private maybeSortResults(name: string, results: ChangeInfo[]) {
-    const userId = this.account?._account_id;
+    // TODO: viewState?.user can be an Email Address. In this case the
+    // attention_set lookups will return undefined.
+    const userId =
+      this.viewState?.user === 'self'
+        ? this.loggedInUser?._account_id
+        : this.viewState?.user;
     const sortedResults = [...results];
     if (name === YOUR_TURN.name && userId) {
       sortedResults.sort((c1, c2) => {
         const c1Owner = c1.owner._account_id === userId;
         const c2Owner = c2.owner._account_id === userId;
         if (c1Owner !== c2Owner) return c1Owner ? 1 : -1;
-        // Should never happen, because the change is in the 'Your Turn'
+        // Should never happen, because the change is in the 'Your turn'
         // section, so the userId should be found in the attention set of both.
         if (!c1.attention_set || !c1.attention_set[userId]) return 0;
         if (!c2.attention_set || !c2.attention_set[userId]) return 0;
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
index 84a3139..58a66a6 100644
--- 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
@@ -36,6 +36,7 @@
 import {SinonStubbedMember} from 'sinon';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 import {GrButton} from '../../shared/gr-button/gr-button';
+import {DashboardType} from '../../../models/views/dashboard';
 
 suite('gr-dashboard-view tests', () => {
   let element: GrDashboardView;
@@ -63,6 +64,7 @@
   test('render', async () => {
     element.viewState = {
       view: GerritView.DASHBOARD,
+      type: DashboardType.CUSTOM,
       user: 'self',
       sections: [
         {name: 'test1', query: 'test1', hideIfEmpty: true},
@@ -117,6 +119,7 @@
     setup(async () => {
       element.viewState = {
         view: GerritView.DASHBOARD,
+        type: DashboardType.CUSTOM,
         user: 'user',
         sections: [
           {name: 'test1', query: 'test1', hideIfEmpty: true},
@@ -155,6 +158,7 @@
     setup(async () => {
       element.viewState = {
         view: GerritView.DASHBOARD,
+        type: DashboardType.CUSTOM,
         user: 'self',
         sections: [
           {name: 'test1', query: 'test1', hideIfEmpty: true},
@@ -167,6 +171,7 @@
       test('not dashboard/self', () => {
         element.viewState = {
           view: GerritView.DASHBOARD,
+          type: DashboardType.USER,
           user: 'notself',
           dashboard: '' as DashboardId,
         };
@@ -178,6 +183,7 @@
         element.results = [];
         element.viewState = {
           view: GerritView.DASHBOARD,
+          type: DashboardType.USER,
           user: 'self',
           dashboard: '' as DashboardId,
         };
@@ -192,6 +198,7 @@
         ];
         element.viewState = {
           view: GerritView.DASHBOARD,
+          type: DashboardType.USER,
           user: 'self',
           dashboard: '' as DashboardId,
         };
@@ -212,6 +219,7 @@
         assert.isFalse(changeIsOpen(element.results[0].results[0]));
         element.viewState = {
           view: GerritView.DASHBOARD,
+          type: DashboardType.USER,
           user: 'self',
           dashboard: '' as DashboardId,
         };
@@ -319,9 +327,10 @@
 
   suite('selfOnly sections', () => {
     test('viewing self dashboard includes selfOnly sections', async () => {
-      element.account = undefined;
+      element.loggedInUser = undefined;
       element.viewState = {
         view: GerritView.DASHBOARD,
+        type: DashboardType.CUSTOM,
         user: 'self',
         dashboard: '' as DashboardId,
         sections: [
@@ -334,9 +343,10 @@
     });
 
     test('viewing dashboard when logged in includes owner:self query', async () => {
-      element.account = createAccountDetailWithId(1);
+      element.loggedInUser = createAccountDetailWithId(1);
       element.viewState = {
         view: GerritView.DASHBOARD,
+        type: DashboardType.CUSTOM,
         user: 'self',
         dashboard: '' as DashboardId,
         sections: [
@@ -353,6 +363,7 @@
     test("viewing another user's dashboard omits selfOnly sections", async () => {
       element.viewState = {
         view: GerritView.DASHBOARD,
+        type: DashboardType.CUSTOM,
         user: 'user',
         dashboard: '' as DashboardId,
         sections: [
@@ -368,6 +379,7 @@
   test('suffixForDashboard is included in getChanges query', async () => {
     element.viewState = {
       view: GerritView.DASHBOARD,
+      type: DashboardType.CUSTOM,
       dashboard: '' as DashboardId,
       sections: [
         {name: '', query: '1'},
@@ -508,6 +520,7 @@
   test('showNewUserHelp', async () => {
     element.viewState = {
       view: GerritView.DASHBOARD,
+      type: DashboardType.USER,
     };
     element.loading = false;
     element.showNewUserHelp = false;
@@ -542,6 +555,7 @@
 
     element.viewState = {
       view: GerritView.DASHBOARD,
+      type: DashboardType.USER,
       dashboard: '' as DashboardId,
       user: 'self',
     };
@@ -551,6 +565,7 @@
     element.loading = false;
     element.viewState = {
       view: GerritView.DASHBOARD,
+      type: DashboardType.USER,
       dashboard: '' as DashboardId,
       user: 'user',
     };
@@ -559,6 +574,7 @@
 
     element.viewState = {
       view: GerritView.DASHBOARD,
+      type: DashboardType.REPO,
       dashboard: '' as DashboardId,
       project: 'p' as RepoName,
       user: 'user',
@@ -584,6 +600,7 @@
     });
     element.viewState = {
       view: GerritView.DASHBOARD,
+      type: DashboardType.REPO,
       dashboard: 'dashboard' as DashboardId,
       project: 'project' as RepoName,
       user: '',
@@ -592,6 +609,18 @@
   });
 
   test('viewState change triggers dashboardDisplayed()', async () => {
+    getChangesStub.returns(Promise.resolve([[]]));
+    const dashboardDisplayedStub = stubReporting('dashboardDisplayed');
+    element.viewState = {
+      view: GerritView.DASHBOARD,
+      type: DashboardType.USER,
+      user: 'self',
+    };
+    await element.reload();
+    assert.isTrue(dashboardDisplayedStub.calledOnce);
+  });
+
+  test('viewState change does not trigger dashboardDisplayed() for repo', async () => {
     stubRestApi('getDashboard').returns(
       Promise.resolve({
         id: '' as DashboardId,
@@ -609,11 +638,24 @@
     const dashboardDisplayedStub = stubReporting('dashboardDisplayed');
     element.viewState = {
       view: GerritView.DASHBOARD,
+      type: DashboardType.REPO,
       dashboard: 'dashboard' as DashboardId,
       project: 'project' as RepoName,
       user: '',
     };
     await element.reload();
-    assert.isTrue(dashboardDisplayedStub.calledOnce);
+    assert.isFalse(dashboardDisplayedStub.calledOnce);
+  });
+
+  test('viewState change does not trigger dashboardDisplayed() for not-self', async () => {
+    getChangesStub.returns(Promise.resolve([]));
+    const dashboardDisplayedStub = stubReporting('dashboardDisplayed');
+    element.viewState = {
+      view: GerritView.DASHBOARD,
+      type: DashboardType.USER,
+      user: 'notself',
+    };
+    await element.reload();
+    assert.isFalse(dashboardDisplayedStub.calledOnce);
   });
 });
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 3f99416..d1bba95 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
@@ -15,7 +15,10 @@
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {LitElement, css, html, PropertyValues} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
-import {createDashboardUrl} from '../../../models/views/dashboard';
+import {
+  DashboardType,
+  createDashboardUrl,
+} from '../../../models/views/dashboard';
 
 @customElement('gr-user-header')
 export class GrUserHeader extends LitElement {
@@ -145,10 +148,12 @@
     if (!accountDetails) return '';
 
     const id = accountDetails._account_id;
-    if (id) return createDashboardUrl({user: String(id)});
+    if (id)
+      return createDashboardUrl({type: DashboardType.USER, user: String(id)});
 
     const email = accountDetails.email;
-    if (email) return createDashboardUrl({user: email});
+    if (email)
+      return createDashboardUrl({type: DashboardType.USER, user: email});
 
     return '';
   }
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 18fcce1..31c2b660 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
@@ -25,7 +25,6 @@
 import {
   changeIsOpen,
   isOwner,
-  ListChangesOption,
   listChangesOptionsToHex,
 } from '../../../utils/change-util';
 import {
@@ -42,13 +41,13 @@
   BranchName,
   ChangeActionDialog,
   ChangeInfo,
-  ChangeViewChangeInfo,
   CherryPickInput,
   CommitId,
   InheritedBooleanInfo,
   isDetailedLabelInfo,
   isQuickLabelInfo,
   LabelInfo,
+  ListChangesOption,
   NumericChangeId,
   PatchSetNumber,
   RequestPayload,
@@ -100,7 +99,7 @@
 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 {customElement, query, state} from 'lit/decorators.js';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {assertIsDefined, queryAll, uuid} from '../../../utils/common-util';
 import {Interaction} from '../../../constants/reporting';
@@ -113,6 +112,9 @@
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {subscribe} from '../../lit/subscription-controller';
+import {userModelToken} from '../../../models/user/user-model';
+import {ParsedChangeInfo} from '../../../types/types';
+import {configModelToken} from '../../../models/config/config-model';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -377,76 +379,54 @@
 
   RevisionActions = RevisionActions;
 
-  @property({type: Object})
-  change?: ChangeViewChangeInfo;
+  @state() change?: ParsedChangeInfo;
 
-  @state()
-  actions: ActionNameToActionInfoMap = {};
+  @state() actions: ActionNameToActionInfoMap = {};
 
-  @property({type: Array})
-  primaryActionKeys: PrimaryActionKey[] = [
+  @state() primaryActionKeys: PrimaryActionKey[] = [
     ChangeActions.READY,
     RevisionActions.SUBMIT,
   ];
 
-  @property({type: Boolean})
-  disableEdit = false;
-
-  // private but used in test
   @state() _hideQuickApproveAction = false;
 
-  @property({type: Object})
-  account?: AccountInfo;
+  @state() account?: AccountInfo;
 
-  @property({type: String})
-  changeNum?: NumericChangeId;
+  @state() changeNum?: NumericChangeId;
 
-  @property({type: String})
-  changeStatus?: ChangeStatus;
+  @state() changeStatus?: ChangeStatus;
 
-  @property({type: String})
-  commitNum?: CommitId;
+  @state() commitNum?: CommitId;
 
   @state() latestPatchNum?: PatchSetNumber;
 
-  @property({type: String})
-  commitMessage = '';
+  @state() commitMessage = '';
 
-  @property({type: Object})
-  revisionActions: ActionNameToActionInfoMap = {};
+  @state() revisionActions: ActionNameToActionInfoMap = {};
 
-  @state() private revisionSubmitAction?: ActionInfo | null;
+  @state() revisionSubmitAction?: ActionInfo | null;
 
-  // used as a proprty type so cannot be private
   @state() revisionRebaseAction?: ActionInfo | null;
 
-  @property({type: String})
-  privateByDefault?: InheritedBooleanInfo;
+  @state() privateByDefault?: InheritedBooleanInfo;
 
-  // private but used in test
   @state() loading = true;
 
-  // private but used in test
   @state() actionLoadingMessage = '';
 
-  @state() private inProgressActionKeys = new Set<string>();
+  @state() inProgressActionKeys = new Set<string>();
 
-  // _computeAllActions always returns an array
-  // private but used in test
   @state() allActionValues: UIActionInfo[] = [];
 
-  // private but used in test
   @state() topLevelActions?: UIActionInfo[];
 
-  // private but used in test
   @state() topLevelPrimaryActions?: UIActionInfo[];
 
-  // private but used in test
   @state() topLevelSecondaryActions?: UIActionInfo[];
 
-  @state() private menuActions?: MenuAction[];
+  @state() menuActions?: MenuAction[];
 
-  @state() private overflowActions: OverflowAction[] = [
+  @state() overflowActions: OverflowAction[] = [
     {
       type: ActionType.CHANGE,
       key: ChangeActions.WIP,
@@ -493,29 +473,21 @@
     },
   ];
 
-  @state() private actionPriorityOverrides: ActionPriorityOverride[] = [];
+  @state() actionPriorityOverrides: ActionPriorityOverride[] = [];
 
-  @state() private additionalActions: UIActionInfo[] = [];
+  @state() additionalActions: UIActionInfo[] = [];
 
-  // private but used in test
   @state() hiddenActions: string[] = [];
 
-  // private but used in test
   @state() disabledMenuActions: string[] = [];
 
-  // private but used in test
-  @state()
-  editPatchsetLoaded = false;
+  @state() editPatchsetLoaded = false;
 
-  @property({type: Boolean})
-  editMode = false;
+  @state() editMode = false;
 
-  // private but used in test
-  @state()
-  editBasedOnCurrentPatchSet = true;
+  @state() editBasedOnCurrentPatchSet = true;
 
-  @property({type: Boolean})
-  loggedIn = false;
+  @state() loggedIn = false;
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -523,6 +495,10 @@
 
   private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
   private readonly getChangeModel = resolve(this, changeModelToken);
 
   private readonly getStorage = resolve(this, storageServiceToken);
@@ -546,6 +522,51 @@
       () => this.getChangeModel().patchNum$,
       x => (this.editPatchsetLoaded = x === 'edit')
     );
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      x => (this.changeNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      x => (this.change = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().status$,
+      x => (this.changeStatus = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().editMode$,
+      x => (this.editMode = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().revision$,
+      rev => (this.commitNum = rev?.commit?.commit)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().latestRevision$,
+      rev => (this.commitMessage = rev?.commit?.message ?? '')
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      x => (this.account = x)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().loggedIn$,
+      x => (this.loggedIn = x)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().repoConfig$,
+      config => (this.privateByDefault = config?.private_by_default)
+    );
   }
 
   override connectedCallback() {
@@ -865,7 +886,7 @@
 
         this.revisionActions = revisionActions;
         this.sendShowRevisionActions({
-          change,
+          change: change as ChangeInfo,
           revisionActions,
         });
         this.handleLoadingComplete();
@@ -1025,18 +1046,7 @@
   }
 
   private editStatusChanged() {
-    // Hide change edits if not logged in
-    if (this.change === undefined || !this.loggedIn) {
-      return;
-    }
-    if (this.disableEdit) {
-      delete this.actions.rebaseEdit;
-      delete this.actions.publishEdit;
-      delete this.actions.deleteEdit;
-      delete this.actions.stopEdit;
-      delete this.actions.edit;
-      return;
-    }
+    if (!this.change || !this.loggedIn) return;
     if (this.editPatchsetLoaded) {
       // Only show actions that mutate an edit if an actual edit patch set
       // is loaded.
@@ -1115,7 +1125,7 @@
     if (!this.change || !this.change.labels || !this.change.permitted_labels) {
       return null;
     }
-    if (this.change && this.change.status === ChangeStatus.MERGED) {
+    if (this.change?.status === ChangeStatus.MERGED) {
       return null;
     }
     let result;
@@ -1317,18 +1327,18 @@
 
   // private but used in test
   canSubmitChange() {
-    if (!this.change) {
-      return false;
-    }
+    if (!this.change) return false;
+    const change = this.change as ChangeInfo;
+    const revision = this.getRevision(change, this.latestPatchNum);
     return this.getPluginLoader().jsApiService.canSubmitChange(
-      this.change,
-      this.getRevision(this.change, this.latestPatchNum)
+      change,
+      revision
     );
   }
 
   // private but used in test
-  getRevision(change: ChangeViewChangeInfo, patchNum?: PatchSetNumber) {
-    for (const rev of Object.values(change.revisions)) {
+  getRevision(change: ChangeInfo, patchNum?: PatchSetNumber) {
+    for (const rev of Object.values(change.revisions ?? {})) {
       if (rev._number === patchNum) {
         return rev;
       }
@@ -1553,6 +1563,7 @@
       base: e.detail.base,
       allow_conflicts: e.detail.allowConflicts,
       on_behalf_of_uploader: e.detail.onBehalfOfUploader,
+      committer_email: e.detail.committerEmail,
     };
     const rebaseChain = !!e.detail.rebaseChain;
     this.fireAction(
@@ -1600,6 +1611,7 @@
         base: el.baseCommit ? el.baseCommit : null,
         message: el.message,
         allow_conflicts: conflicts,
+        committer_email: el.committerEmail ? el.committerEmail : null,
       }
     );
   }
@@ -1799,7 +1811,7 @@
     if (dialog.init) dialog.init();
     dialog.hidden = false;
     assertIsDefined(this.actionsModal, 'actionsModal');
-    this.actionsModal.showModal();
+    if (this.actionsModal.isConnected) this.actionsModal.showModal();
     whenVisible(dialog, () => {
       if (dialog.resetFocus) {
         dialog.resetFocus();
@@ -1812,7 +1824,7 @@
   // private but used in test
   setReviewOnRevert(newChangeId: NumericChangeId) {
     const review = this.getPluginLoader().jsApiService.getReviewPostRevert(
-      this.change
+      this.change as ChangeInfo
     );
     if (!review) {
       return Promise.resolve(undefined);
@@ -1821,67 +1833,77 @@
   }
 
   // private but used in test
-  handleResponse(action: UIActionInfo, response?: Response) {
+  async 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))
-            .then(() => {
-              this.getNavigation().setUrl(
-                createChangeUrl({change: revertChangeInfo})
-              );
-            });
-          break;
-        }
-        case RevisionActions.CHERRYPICK: {
-          const cherrypickChangeInfo: ChangeInfo = obj as unknown as ChangeInfo;
-          this.waitForChangeReachable(cherrypickChangeInfo._number).then(() => {
-            this.getNavigation().setUrl(
-              createChangeUrl({change: cherrypickChangeInfo})
-            );
-          });
-          break;
-        }
-        case ChangeActions.DELETE:
-          if (action.__type === ActionType.CHANGE) {
-            this.getNavigation().setUrl(rootUrl());
-          }
-          break;
-        case ChangeActions.WIP:
-        case ChangeActions.DELETE_EDIT:
-        case ChangeActions.PUBLISH_EDIT:
-        case ChangeActions.REBASE_EDIT:
-        case ChangeActions.REBASE:
-        case ChangeActions.SUBMIT:
-          // Hide rebase dialog only if the action succeeds
-          this.actionsModal?.close();
-          this.hideAllDialogs();
-          this.getChangeModel().navigateToChangeResetReload();
-          break;
-        case ChangeActions.REVERT_SUBMISSION: {
-          const revertSubmistionInfo = obj as unknown as RevertSubmissionInfo;
-          if (
-            !revertSubmistionInfo.revert_changes ||
-            !revertSubmistionInfo.revert_changes.length
-          )
-            return;
-          /* If there is only 1 change then gerrit will automatically
-            redirect to that change */
-          const topic = revertSubmistionInfo.revert_changes[0].topic;
-          this.getNavigation().setUrl(createSearchUrl({topic}));
-          break;
-        }
-        default:
-          this.getChangeModel().navigateToChangeResetReload();
-          break;
+    const obj = await this.restApiService.getResponseObject(response);
+    switch (action.__key) {
+      case ChangeActions.REVERT: {
+        const revertChangeInfo: ChangeInfo = obj as unknown as ChangeInfo;
+        this.restApiService.setInProjectLookup(
+          revertChangeInfo._number,
+          revertChangeInfo.project
+        );
+        const reachable = await this.waitForChangeReachable(
+          revertChangeInfo._number
+        );
+        if (!reachable) return;
+        await this.setReviewOnRevert(revertChangeInfo._number);
+        this.getNavigation().setUrl(
+          createChangeUrl({change: revertChangeInfo})
+        );
+        break;
       }
-    });
+      case RevisionActions.CHERRYPICK: {
+        const cherrypickChangeInfo: ChangeInfo = obj as unknown as ChangeInfo;
+        this.restApiService.setInProjectLookup(
+          cherrypickChangeInfo._number,
+          cherrypickChangeInfo.project
+        );
+        const reachable = this.waitForChangeReachable(
+          cherrypickChangeInfo._number
+        );
+        if (!reachable) return;
+        this.getNavigation().setUrl(
+          createChangeUrl({change: cherrypickChangeInfo})
+        );
+        break;
+      }
+      case ChangeActions.DELETE:
+        if (action.__type === ActionType.CHANGE) {
+          this.getNavigation().setUrl(rootUrl());
+        }
+        break;
+      case ChangeActions.WIP:
+      case ChangeActions.DELETE_EDIT:
+      case ChangeActions.PUBLISH_EDIT:
+      case ChangeActions.REBASE_EDIT:
+      case ChangeActions.REBASE:
+      case ChangeActions.SUBMIT:
+        // Hide rebase dialog only if the action succeeds
+        this.actionsModal?.close();
+        this.hideAllDialogs();
+        this.getChangeModel().navigateToChangeResetReload();
+        break;
+      case ChangeActions.REVERT_SUBMISSION: {
+        const revertSubmistionInfo = obj as unknown as RevertSubmissionInfo;
+        if (
+          !revertSubmistionInfo.revert_changes ||
+          !revertSubmistionInfo.revert_changes.length
+        )
+          return;
+        /* If there is only 1 change then gerrit will automatically
+          redirect to that change */
+        const topic = revertSubmistionInfo.revert_changes[0].topic;
+        this.getNavigation().setUrl(createSearchUrl({topic}));
+        break;
+      }
+      default:
+        this.getChangeModel().navigateToChangeResetReload();
+        break;
+    }
   }
 
   // private but used in test
@@ -1973,18 +1995,27 @@
   }
 
   // private but used in test
-  handleCherrypickTap() {
+  async handleCherrypickTap() {
     if (!this.change) {
       throw new Error('The change property must be set');
     }
     assertIsDefined(this.confirmCherrypick, 'confirmCherrypick');
     this.confirmCherrypick.branch = '' as BranchName;
+    const changes = await this.getCherryPickChanges();
+    if (!changes.length) return;
+    this.confirmCherrypick.updateChanges(changes);
+    this.showActionDialog(this.confirmCherrypick);
+  }
+
+  private async getCherryPickChanges() {
+    if (!this.change) return [];
+    if (!this.change.topic) return [this.change];
     const query = `topic: "${this.change.topic}"`;
     const options = listChangesOptionsToHex(
       ListChangesOption.MESSAGES,
       ListChangesOption.ALL_REVISIONS
     );
-    this.restApiService
+    return this.restApiService
       .getChanges(0, query, undefined, options)
       .then(changes => {
         if (!changes) {
@@ -1992,10 +2023,9 @@
             'Change Actions',
             new Error('getChanges returns undefined')
           );
-          return;
+          return [];
         }
-        this.confirmCherrypick!.updateChanges(changes);
-        this.showActionDialog(this.confirmCherrypick!);
+        return changes;
       });
   }
 
@@ -2176,14 +2206,15 @@
    *
    * private but used in test
    */
-  waitForChangeReachable(changeNum: NumericChangeId) {
+  waitForChangeReachable(changeNum: NumericChangeId): Promise<boolean> {
     let attemptsRemaining = AWAIT_CHANGE_ATTEMPTS;
     return new Promise(resolve => {
       const check = () => {
         attemptsRemaining--;
-        // Pass a no-op error handler to avoid the "not found" error toast.
+        // Pass a no-op error handler to avoid the "not found" error toast,
+        // unless it's the last attempt
         this.restApiService
-          .getChange(changeNum, () => {})
+          .getChange(changeNum, attemptsRemaining !== 0 ? () => {} : undefined)
           .then(response => {
             // If the response is 404, the response will be undefined.
             if (response) {
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 946191b..b953eec 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
@@ -129,6 +129,7 @@
       element = await fixture<GrChangeActions>(html`
         <gr-change-actions></gr-change-actions>
       `);
+      element.changeStatus = ChangeStatus.NEW;
       element.change = {
         ...createChangeViewChange(),
         actions: {
@@ -155,7 +156,7 @@
       await element.reload();
     });
 
-    test('render', () => {
+    test('render', async () => {
       assert.shadowDom.equal(
         element,
         /* HTML */ `
@@ -200,6 +201,24 @@
                   Rebase
                 </gr-button>
               </gr-tooltip-content>
+              <gr-tooltip-content
+                has-tooltip=""
+                position-below=""
+                title="Edit this change"
+              >
+                <gr-button
+                  aria-disabled="false"
+                  class="edit"
+                  data-action-key="edit"
+                  data-label="Edit"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  <gr-icon filled="" icon="edit"> </gr-icon>
+                  Edit
+                </gr-button>
+              </gr-tooltip-content>
             </section>
             <gr-button
               aria-disabled="false"
@@ -610,6 +629,7 @@
             allowConflicts: false,
             rebaseChain: false,
             onBehalfOfUploader: true,
+            committerEmail: 'test@default.org',
           },
         })
       );
@@ -617,7 +637,12 @@
         '/rebase',
         assertUIActionInfo(rebaseAction),
         true,
-        {base: '1234', allow_conflicts: false, on_behalf_of_uploader: true},
+        {
+          base: '1234',
+          allow_conflicts: false,
+          on_behalf_of_uploader: true,
+          committer_email: 'test@default.org',
+        },
         {allow_conflicts: false, on_behalf_of_uploader: true},
       ]);
     });
@@ -692,7 +717,7 @@
         )
         .returns(review);
       const saveStub = stubRestApi('saveChangeReview').returns(
-        Promise.resolve(new Response())
+        Promise.resolve({})
       );
       const setReviewOnRevert = element.setReviewOnRevert(changeId) as Promise<
         undefined | Response
@@ -705,29 +730,6 @@
     });
 
     suite('change edits', () => {
-      test('disableEdit', async () => {
-        element.editMode = false;
-        element.editBasedOnCurrentPatchSet = false;
-        element.change = {
-          ...createChangeViewChange(),
-          status: ChangeStatus.NEW,
-        };
-        element.disableEdit = true;
-        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"]'));
-      });
-
       test('shows confirm dialog for delete edit', async () => {
         element.loggedIn = true;
         element.editMode = true;
@@ -1004,6 +1006,7 @@
             base: null,
             message: 'foo message',
             allow_conflicts: false,
+            committer_email: null,
           },
         ]);
       });
@@ -1052,6 +1055,7 @@
             base: null,
             message: 'foo message',
             allow_conflicts: true,
+            committer_email: null,
           },
         ]);
       });
@@ -1093,8 +1097,9 @@
           },
         ];
         setup(async () => {
+          element.change!.topic = 'T' as TopicName;
           stubRestApi('getChanges').returns(Promise.resolve(changes));
-          element.handleCherrypickTap();
+          await element.handleCherrypickTap();
           await element.updateComplete;
           const confirmCherrypick = queryAndAssert<GrConfirmCherrypickDialog>(
             element,
@@ -2365,7 +2370,7 @@
             });
           } else {
             numTries--;
-            return Promise.resolve(null);
+            return Promise.resolve(undefined);
           }
         };
 
@@ -2462,42 +2467,18 @@
           );
         });
 
-        suite('show revert submission dialog', () => {
-          setup(async () => {
-            element.change!.submission_id = '199' as ChangeSubmissionId;
-            element.change!.current_revision = '2000' as CommitId;
-            stubRestApi('getChanges').returns(
-              Promise.resolve([
-                {
-                  ...createChangeViewChange(),
-                  change_id: '12345678901234' as ChangeId,
-                  topic: 'T' as TopicName,
-                  subject: 'random',
-                },
-                {
-                  ...createChangeViewChange(),
-                  change_id: '23456' as ChangeId,
-                  topic: 'T' as TopicName,
-                  subject: 'a'.repeat(100),
-                },
-              ])
-            );
-            await element.updateComplete;
-          });
-        });
-
         suite('single changes revert', () => {
           let setUrlStub: sinon.SinonStub;
           setup(() => {
+            setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+          });
+
+          test('revert submission single change', async () => {
             getResponseObjectStub.returns(
               Promise.resolve({
                 revert_changes: [{change_id: 12345, topic: 'T'}],
               })
             );
-            setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
-          });
-
-          test('revert submission single change', async () => {
             await element.send(
               HttpMethod.POST,
               {message: 'Revert submission'},
@@ -2517,6 +2498,37 @@
             assert.isTrue(setUrlStub.called);
             assert.equal(setUrlStub.lastCall.args[0], '/q/topic:"T"');
           });
+
+          test('revert single change', async () => {
+            getResponseObjectStub.returns(
+              Promise.resolve({
+                change_id: 12345,
+                project: 'projectId',
+                _number: 12345,
+              })
+            );
+            stubRestApi('getChange').returns(
+              Promise.resolve(createChangeViewChange())
+            );
+            await element.send(
+              HttpMethod.POST,
+              {message: 'Revert'},
+              '/revert',
+              false,
+              cleanup,
+              {} as UIActionInfo
+            );
+            await element.handleResponse(
+              {
+                __key: 'revert',
+                __type: ActionType.CHANGE,
+                label: 'l',
+              },
+              new Response()
+            );
+            assert.isTrue(setUrlStub.called);
+            assert.equal(setUrlStub.lastCall.args[0], '/c/projectId/+/12345');
+          });
         });
 
         suite('multiple changes revert', () => {
@@ -2640,6 +2652,66 @@
               assert.isTrue(handleErrorStub.called);
             });
         });
+
+        test('revert single change change not reachable', async () => {
+          stubRestApi('getChangeDetail').returns(
+            Promise.resolve({
+              ...createChangeViewChange(),
+              // element has latest info
+              revisions: createRevisions(element.latestPatchNum as number),
+              messages: createChangeMessages(1),
+            })
+          );
+          getResponseObjectStub = stubRestApi('getResponseObject');
+          const setUrlStub = sinon.stub(
+            testResolver(navigationToken),
+            'setUrl'
+          );
+          const setReviewOnRevertStub = sinon.stub(
+            element,
+            'setReviewOnRevert'
+          );
+          getResponseObjectStub.returns(
+            Promise.resolve({
+              change_id: 12345,
+              project: 'projectId',
+              _number: 12345,
+            })
+          );
+          let errorFired = false;
+          // Mimics the behaviour of gr-rest-api-impl: If errFn is passed call
+          // it and return undefined, otherwise call fireNetworkError or
+          // fireServerError.
+          stubRestApi('getChange').callsFake((_, errFn) => {
+            if (errFn) {
+              errFn.call(undefined);
+            } else {
+              errorFired = true;
+            }
+            return Promise.resolve(undefined);
+          });
+
+          await element.send(
+            HttpMethod.POST,
+            {message: 'Revert'},
+            '/revert',
+            false,
+            cleanup,
+            {} as UIActionInfo
+          );
+          await element.handleResponse(
+            {
+              __key: 'revert',
+              __type: ActionType.CHANGE,
+              label: 'l',
+            },
+            new Response()
+          );
+
+          assert.isTrue(errorFired);
+          assert.isFalse(setUrlStub.called);
+          assert.isFalse(setReviewOnRevertStub.called);
+        });
       });
     });
 
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 b008bcc..0ba5d32 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
@@ -82,6 +82,13 @@
 import {createChangeUrl} from '../../../models/views/change';
 import {getChangeWeblinks} from '../../../utils/weblink-util';
 import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {subscribe} from '../../lit/subscription-controller';
+import {userModelToken} from '../../../models/user/user-model';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {changeModelToken} from '../../../models/change/change-model';
+import {relatedChangesModelToken} from '../../../models/change/related-changes-model';
+import {truncatePath} from '../../../utils/path-list-util';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
@@ -118,156 +125,194 @@
 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;
-
-  // TODO: Just use `revision.commit` instead.
-  @property({type: Object}) commitInfo?: CommitInfoWithRequiredCommit;
-
-  @property({type: Object}) serverConfig?: ServerInfo;
-
+  // TODO: Convert to @state. That requires the change model to keep track of
+  // current revision actions. Then we can also get rid of the
+  // `revision-actions-changed` event.
   @property({type: Boolean}) parentIsCurrent?: boolean;
 
-  @property({type: Object}) repoConfig?: ConfigInfo;
+  @state() change?: ParsedChangeInfo;
 
-  // private but used in test
+  @state() revertedChange?: ChangeInfo;
+
+  @state() account?: AccountDetailInfo;
+
+  @state() revision?: RevisionInfo | EditRevisionInfo;
+
+  @state() serverConfig?: ServerInfo;
+
+  @state() repoConfig?: ConfigInfo;
+
   @state() mutable = false;
 
-  @state() private readonly notCurrentMessage = NOT_CURRENT_MESSAGE;
+  @state() 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;
+  @state() pushCertificateValidation?: PushCertificateValidationInfo;
 
-  // private but used in test
   @state() settingTopic = false;
 
-  // private but used in test
   @state() currentParents: ParentCommitInfo[] = [];
 
-  @state() private showAllSections = false;
+  @state() showAllSections = false;
 
-  @state() private queryTopic?: AutocompleteQuery;
+  @state() queryTopic?: AutocompleteQuery;
 
-  @state() private queryHashtag?: AutocompleteQuery;
+  @state() queryHashtag?: AutocompleteQuery;
 
   private restApiService = getAppContext().restApiService;
 
   private readonly reporting = getAppContext().reportingService;
 
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly getRelatedChangesModel = resolve(
+    this,
+    relatedChangesModelToken
+  );
+
   constructor() {
     super();
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      serverConfig => (this.serverConfig = serverConfig)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().repoConfig$,
+      repoConfig => (this.repoConfig = repoConfig)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      account => (this.account = account)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      change => (this.change = change)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().revision$,
+      revision => (this.revision = revision)
+    );
+    subscribe(
+      this,
+      () => this.getRelatedChangesModel().revertingChange$,
+      revertingChange => (this.revertedChange = revertingChange)
+    );
     this.queryTopic = (input: string) => this.getTopicSuggestions(input);
     this.queryHashtag = (input: string) => this.getHashtagSuggestions(input);
   }
 
-  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;
-      }
-      gr-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
+  static override get styles() {
+    return [
+      sharedStyles,
+      fontStyles,
+      changeMetadataStyles,
+      css`
+        :host {
+          display: table;
+        }
+        gr-submit-requirements {
+          --requirements-horizontal-padding: var(--metadata-horizontal-padding);
+        }
+        gr-editable-label {
+          max-width: 9em;
+        }
+        gr-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;
-      }
-    `,
-  ];
+          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;
+        }
+      `,
+    ];
+  }
 
   override render() {
     if (!this.change) return nothing;
@@ -283,7 +328,7 @@
       ${this.renderCCs()} ${this.renderProjectBranch()} ${this.renderParent()}
       ${this.renderMergedAs()} ${this.renderShowRevertCreatedAs()}
       ${this.renderTopic()} ${this.renderCherryPickOf()}
-      ${this.renderStrategy()} ${this.renderHashTags()}
+      ${this.renderRevertOf()} ${this.renderStrategy()} ${this.renderHashTags()}
       ${this.renderSubmitRequirements()} ${this.renderWeblinks()}
       <gr-endpoint-decorator name="change-metadata-item">
         <gr-endpoint-param
@@ -495,11 +540,11 @@
             </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
+              href=${this.computeProjectUrl(change.project)}
+              .title=${change.project}
+            >
+              ${truncatePath(change.project, 3)}
             </a>
           </span>
         </section>
@@ -643,6 +688,23 @@
     </section>`;
   }
 
+  private renderRevertOf() {
+    if (!this.change?.revert_of) return nothing;
+    return html` <section class=${this.computeDisplayState(Metadata.REVERT_OF)}>
+      <span class="title">Revert of</span>
+      <span class="value">
+        <a
+          href=${createChangeUrl({
+            changeNum: this.change.revert_of,
+            repo: this.change.project,
+            usp: 'metadata',
+          })}
+          >${this.change.revert_of}</a
+        >
+      </span>
+    </section>`;
+  }
+
   private renderStrategy() {
     if (!changeIsOpen(this.change)) return nothing;
     return html`<section
@@ -735,7 +797,10 @@
 
   // private but used in test
   computeWebLinks(): WebLinkInfo[] {
-    return getChangeWeblinks(this.commitInfo?.web_links, this.serverConfig);
+    return getChangeWeblinks(
+      this.revision?.commit?.web_links,
+      this.serverConfig
+    );
   }
 
   private computeStrategy() {
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 c46fe24..93ef3e3 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
@@ -9,7 +9,6 @@
 import {ChangeRole, GrChangeMetadata} from './gr-change-metadata';
 import {
   createServerInfo,
-  createUserConfig,
   createParsedChange,
   createAccountWithId,
   createCommitInfoWithRequiredCommit,
@@ -61,18 +60,9 @@
   let element: GrChangeMetadata;
 
   setup(async () => {
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    stubRestApi('getConfig').returns(
-      Promise.resolve({
-        ...createServerInfo(),
-        user: {
-          ...createUserConfig(),
-          anonymouscowardname: 'test coward name',
-        },
-      })
-    );
     element = await fixture(html`<gr-change-metadata></gr-change-metadata>`);
     element.change = createParsedChange();
+    element.account = undefined;
     await element.updateComplete;
   });
 
@@ -251,16 +241,22 @@
   });
 
   test('weblinks hidden when no weblinks', async () => {
-    element.commitInfo = createCommitInfoWithRequiredCommit();
+    element.revision = {
+      ...createRevision(),
+      commit: createCommitInfoWithRequiredCommit(),
+    };
     element.serverConfig = createServerInfo();
     await element.updateComplete;
     assert.isNull(element.webLinks);
   });
 
   test('weblinks hidden when only gitiles weblink', async () => {
-    element.commitInfo = {
-      ...createCommitInfoWithRequiredCommit(),
-      web_links: [{...createWebLinkInfo(), name: 'gitiles', url: '#'}],
+    element.revision = {
+      ...createRevision(),
+      commit: {
+        ...createCommitInfoWithRequiredCommit(),
+        web_links: [{...createWebLinkInfo(), name: 'gitiles', url: '#'}],
+      },
     };
     element.serverConfig = createServerInfo();
     await element.updateComplete;
@@ -270,9 +266,12 @@
 
   test('weblinks hidden when sole weblink is set as primary', async () => {
     const browser = 'browser';
-    element.commitInfo = {
-      ...createCommitInfoWithRequiredCommit(),
-      web_links: [{...createWebLinkInfo(), name: browser, url: '#'}],
+    element.revision = {
+      ...createRevision(),
+      commit: {
+        ...createCommitInfoWithRequiredCommit(),
+        web_links: [{...createWebLinkInfo(), name: browser, url: '#'}],
+      },
     };
     element.serverConfig = {
       ...createServerInfo(),
@@ -286,9 +285,12 @@
   });
 
   test('weblinks are visible when other weblinks', async () => {
-    element.commitInfo = {
-      ...createCommitInfoWithRequiredCommit(),
-      web_links: [{...createWebLinkInfo(), name: 'test', url: '#'}],
+    element.revision = {
+      ...createRevision(),
+      commit: {
+        ...createCommitInfoWithRequiredCommit(),
+        web_links: [{...createWebLinkInfo(), name: 'test', url: '#'}],
+      },
     };
     await element.updateComplete;
     const webLinks = element.webLinks!;
@@ -297,12 +299,15 @@
   });
 
   test('weblinks are visible when gitiles and other weblinks', async () => {
-    element.commitInfo = {
-      ...createCommitInfoWithRequiredCommit(),
-      web_links: [
-        {...createWebLinkInfo(), name: 'test', url: '#'},
-        {...createWebLinkInfo(), name: 'gitiles', url: '#'},
-      ],
+    element.revision = {
+      ...createRevision(),
+      commit: {
+        ...createCommitInfoWithRequiredCommit(),
+        web_links: [
+          {...createWebLinkInfo(), name: 'test', url: '#'},
+          {...createWebLinkInfo(), name: 'gitiles', url: '#'},
+        ],
+      },
     };
     await element.updateComplete;
     const webLinks = element.webLinks!;
@@ -468,7 +473,7 @@
     });
 
     test('Push Certificate Validation test BAD', () => {
-      change!.revisions.rev1!.push_certificate = {
+      change!.revisions.rev1.push_certificate = {
         certificate: 'Push certificate',
         key: {
           status: GpgKeyInfoStatus.BAD,
@@ -488,7 +493,7 @@
     });
 
     test('Push Certificate Validation test TRUSTED', () => {
-      change!.revisions.rev1!.push_certificate = {
+      change!.revisions.rev1.push_certificate = {
         certificate: 'Push certificate',
         key: {
           status: GpgKeyInfoStatus.TRUSTED,
@@ -526,7 +531,7 @@
     });
 
     test('isEnabledSignedPushOnRepo', () => {
-      change!.revisions.rev1!.push_certificate = {
+      change!.revisions.rev1.push_certificate = {
         certificate: 'Push certificate',
         key: {
           status: GpgKeyInfoStatus.TRUSTED,
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 f6a40a2..f6099fa 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
@@ -12,12 +12,8 @@
 import {subscribe} from '../../lit/subscription-controller';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {getAppContext} from '../../../services/app-context';
-import {
-  CheckResult,
-  CheckRun,
-  ErrorMessages,
-} from '../../../models/checks/checks-model';
-import {Action, Category, RunStatus} from '../../../api/checks';
+import {CheckRun, ErrorMessages} from '../../../models/checks/checks-model';
+import {Action, Category, CheckResult, RunStatus} from '../../../api/checks';
 import {fireShowTab} from '../../../utils/event-util';
 import {
   compareByWorstCategory,
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
index 2ab8ac3..65a19fe 100644
--- 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
@@ -203,6 +203,7 @@
         <a
           href=${link}
           target="_blank"
+          rel="noopener noreferrer"
           @click=${this.onLinkClick}
           @keydown=${this.onLinkKeyDown}
           aria-label="Link to check details"
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
index 7cd019a..412f042 100644
--- 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
@@ -76,6 +76,7 @@
             aria-label="Link to check details"
             href="http://www.google.com"
             target="_blank"
+            rel="noopener noreferrer"
           >
             <gr-icon class="launch" icon="open_in_new"> </gr-icon>
           </a>
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 fc8bcb9..24355be 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
@@ -24,6 +24,7 @@
 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-revision-parents/gr-revision-parents';
 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';
@@ -60,7 +61,6 @@
   BasePatchSetNum,
   ChangeInfo,
   CommentThread,
-  ConfigInfo,
   DetailedLabelInfo,
   EDIT,
   LabelNameToInfoMap,
@@ -88,7 +88,11 @@
 import {isUnresolved} from '../../../utils/comment-util';
 import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
 import {GrFileList} from '../gr-file-list/gr-file-list';
-import {EditRevisionInfo, ParsedChangeInfo} from '../../../types/types';
+import {
+  EditRevisionInfo,
+  LoadingStatus,
+  ParsedChangeInfo,
+} from '../../../types/types';
 import {
   EditableContentSaveEvent,
   FileActionTapEvent,
@@ -101,12 +105,7 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrMessagesList} from '../gr-messages-list/gr-messages-list';
 import {GrThreadList} from '../gr-thread-list/gr-thread-list';
-import {
-  fireAlert,
-  fireDialogChange,
-  fire,
-  fireReload,
-} from '../../../utils/event-util';
+import {fireAlert, fire, fireReload} from '../../../utils/event-util';
 import {
   debounce,
   DelayedTask,
@@ -125,7 +124,6 @@
   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';
@@ -154,10 +152,13 @@
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {relatedChangesModelToken} from '../../../models/change/related-changes-model';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {assign} from '../../../utils/location-util';
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
 
 const REVIEWERS_REGEX = /^(R|CC)=/gm;
+
 const MIN_CHECK_INTERVAL_SECS = 0;
 
 const ACCIDENTAL_STARRING_LIMIT_MS = 10 * 1000;
@@ -166,6 +167,8 @@
 
 const PREFIX = '#message-';
 
+const ROBOT_COMMENTS_LIMIT = 10;
+
 const ReloadToastMessage = {
   NEWER_REVISION: 'A newer patch set has been uploaded',
   RESTORED: 'This change has been restored',
@@ -174,9 +177,6 @@
   NEW_MESSAGE: 'There are new messages on this change',
 };
 
-// Making the tab names more unique in case a plugin adds one with same name
-const ROBOT_COMMENTS_LIMIT = 10;
-
 @customElement('gr-change-view')
 export class GrChangeView extends LitElement {
   /**
@@ -264,12 +264,7 @@
   private account?: AccountDetailInfo;
 
   canStartReview() {
-    return !!(
-      this.change &&
-      this.change.actions &&
-      this.change.actions.ready &&
-      this.change.actions.ready.enabled
-    );
+    return !!this.change?.actions?.ready?.enabled;
   }
 
   // Use change getter/setter instead.
@@ -323,9 +318,6 @@
   loading?: boolean;
 
   @state()
-  private projectConfig?: ConfigInfo;
-
-  @state()
   private shownFileCount?: number;
 
   // Private but used in tests.
@@ -335,10 +327,7 @@
   @state()
   private updateCheckTimerHandle?: number | null;
 
-  // Private but used in tests.
-  getEditMode(): boolean {
-    return !!this.viewState?.edit || this.patchNum === EDIT;
-  }
+  @state() editMode = false;
 
   isSubmitEnabled(): boolean {
     return !!(
@@ -375,11 +364,6 @@
   @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
-  @state()
-  private changeViewAriaHidden = false;
-
   /**
    * This can be a string only for plugin provided tabs.
    */
@@ -425,11 +409,17 @@
   @state()
   replyModalOpened = false;
 
+  @state() private loginUrl = '';
+
+  @state() private loginText = '';
+
   // Accessed in tests.
   readonly reporting = getAppContext().reportingService;
 
   private readonly getChecksModel = resolve(this, checksModelToken);
 
+  readonly flagService = getAppContext().flagsService;
+
   readonly restApiService = getAppContext().restApiService;
 
   private readonly getPluginLoader = resolve(this, pluginLoaderToken);
@@ -664,6 +654,11 @@
     );
     subscribe(
       this,
+      () => this.getChangeModel().editMode$,
+      editMode => (this.editMode = editMode)
+    );
+    subscribe(
+      this,
       () => this.getChangeModel().patchNum$,
       patchNum => (this.patchNum = patchNum)
     );
@@ -720,10 +715,13 @@
     );
     subscribe(
       this,
-      () => this.getConfigModel().repoConfig$,
-      config => {
-        this.projectConfig = config;
-      }
+      () => this.getConfigModel().loginUrl$,
+      loginUrl => (this.loginUrl = loginUrl)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().loginText$,
+      loginText => (this.loginText = loginText)
     );
     subscribe(
       this,
@@ -877,7 +875,7 @@
           margin-left: var(--spacing-xs);
         }
         gr-reply-dialog {
-          width: 60em;
+          width: calc(min(60em, 90vw));
         }
         .changeStatus {
           text-transform: capitalize;
@@ -1092,9 +1090,8 @@
             margin: 0;
           }
           gr-reply-dialog {
-            height: 100vh;
-            min-width: initial;
-            width: 100vw;
+            height: 90vh;
+            width: initial;
           }
         }
         .patch-set-dropdown {
@@ -1123,34 +1120,22 @@
 
   private renderMainContent() {
     return html`
-      <div
-        id="mainContent"
-        class="container"
-        ?hidden=${this.loading}
-        aria-hidden=${this.changeViewAriaHidden ? 'true' : 'false'}
-      >
+      <div id="mainContent" class="container" ?hidden=${this.loading}>
         ${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>
+      <gr-apply-fix-dialog id="applyFixDialog"></gr-apply-fix-dialog>
       <dialog id="downloadModal" tabindex="-1">
         <gr-download-dialog
           id="downloadDialog"
-          .change=${this.change}
-          .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>
@@ -1161,7 +1146,6 @@
             <gr-reply-dialog
               id="replyDialog"
               .permittedLabels=${this.change?.permitted_labels}
-              .projectConfig=${this.projectConfig}
               .canBeStarted=${this.canStartReview()}
               @send=${this.handleReplySent}
               @cancel=${this.handleReplyCancel}
@@ -1270,7 +1254,7 @@
       {
         label: 'Change-Id',
         shortcut: 'd',
-        value: `${this.change?.id.split('~').pop()}`,
+        value: `${this.change?.change_id}`,
       },
     ];
     if (
@@ -1287,27 +1271,18 @@
   }
 
   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}
-        .account=${this.account}
-        .changeNum=${this.changeNum}
-        .changeStatus=${this.change?.status}
-        .commitNum=${this.revision?.commit?.commit}
-        .commitMessage=${this.latestCommitMessage}
-        .editMode=${this.getEditMode()}
-        .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>`;
+    return html`
+      <div class="commitActions">
+        <gr-change-actions
+          id="actions"
+          @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() {
@@ -1315,20 +1290,13 @@
       this.loggedIn,
       this.editingCommitMessage,
       this.change,
-      this.getEditMode()
+      this.editMode
     );
     return html` <div class="changeInfo">
       <div class="changeInfo-column changeMetadata">
         <gr-change-metadata
           id="metadata"
-          .change=${this.change}
-          .revertedChange=${this.revertingChange}
-          .account=${this.account}
-          .revision=${this.revision}
-          .commitInfo=${this.revision?.commit}
-          .serverConfig=${this.serverConfig}
           .parentIsCurrent=${this.isParentCurrent()}
-          .repoConfig=${this.projectConfig}
           @show-reply-dialog=${this.handleShowReplyDialog}
         >
         </gr-change-metadata>
@@ -1345,7 +1313,6 @@
                   Shortcut.OPEN_REPLY_DIALOG,
                   ShortcutSection.ACTIONS
                 )}
-                ?hidden=${!this.loggedIn}
                 primary=""
                 .disabled=${this.replyDisabled}
                 @click=${this.handleReplyTap}
@@ -1419,7 +1386,7 @@
         )}
         ${this.pluginTabsHeaderEndpoints.map(
           tabHeader => html`
-            <paper-tab data-name=${tabHeader}>
+            <paper-tab data-name=${tabHeader} @click=${this.onPaperTabClick}>
               <gr-endpoint-decorator name=${tabHeader}>
                 <gr-endpoint-param name="change" .value=${this.change}>
                 </gr-endpoint-param>
@@ -1458,10 +1425,9 @@
           id="fileListHeader"
           .account=${this.account}
           .change=${this.change}
-          .changeNum=${this.changeNum}
           .commitInfo=${this.revision?.commit}
           .changeUrl=${this.computeChangeUrl()}
-          .editMode=${this.getEditMode()}
+          .editMode=${this.editMode}
           .loggedIn=${this.loggedIn}
           .shownFileCount=${this.shownFileCount}
           .filesExpanded=${this.fileList?.filesExpanded}
@@ -1471,11 +1437,15 @@
           @collapse-diffs=${this.collapseAllDiffs}
         >
         </gr-file-list-header>
+        ${when(
+          this.flagService.isEnabled(KnownExperimentId.REVISION_PARENTS_DATA),
+          () => html`<gr-revision-parents></gr-revision-parents>`
+        )}
         <gr-file-list
           id="fileList"
           .change=${this.change}
           .changeNum=${this.changeNum}
-          .editMode=${this.getEditMode()}
+          .editMode=${this.editMode}
           @files-shown-changed=${(e: CustomEvent<{length: number}>) => {
             this.shownFileCount = e.detail.length;
           }}
@@ -1727,7 +1697,6 @@
 
     const options = {
       mergeable: this.mergeable,
-      submitEnabled: !!this.isSubmitEnabled(),
       revertingChangeStatus: this.revertingChange?.status,
     };
     return changeStatuses(this.change as ChangeInfo, options);
@@ -1837,14 +1806,18 @@
     );
   }
 
-  private handleReplyTap(e: MouseEvent) {
-    e.preventDefault();
-    this.openReplyDialog(FocusTarget.ANY);
+  private handleReplyTap() {
+    if (this.loggedIn) {
+      this.openReplyDialog(FocusTarget.ANY);
+    } else {
+      // We are not using `this.getNavigation().setUrl()`, because the login
+      // page is served directly from the backend and is not part of the web
+      // app.
+      assign(window.location, this.loginUrl);
+    }
   }
 
   private onReplyModalCanceled() {
-    fireDialogChange(this, {canceled: true});
-    this.changeViewAriaHidden = false;
     this.replyModalOpened = false;
   }
 
@@ -1898,7 +1871,7 @@
   handleReplySent() {
     assertIsDefined(this.replyModal);
     this.replyModal.close();
-    this.getChangeModel().navigateToChangeResetReload();
+    this.getCommentsModel().reloadAllComments();
   }
 
   private handleReplyCancel() {
@@ -1957,7 +1930,7 @@
       change: this.change,
       patchNum: this.patchNum,
       basePatchNum: this.basePatchNum,
-      edit: this.getEditMode(),
+      edit: this.editMode,
       messageHash: hash,
     });
     history.replaceState(null, '', url);
@@ -2030,6 +2003,9 @@
 
   // Private but used in tests.
   computeReplyButtonLabel() {
+    if (!this.loggedIn) {
+      return this.loginText;
+    }
     let label = this.canStartReview() ? 'Start Review' : 'Reply';
     if (this.draftCount > 0) {
       label += ` (${this.draftCount})`;
@@ -2089,7 +2065,7 @@
           fire(this, 'hide-alert', {});
         });
     }
-    this.change = newChange;
+    this.getChangeModel().updateStateChange(newChange);
   }
 
   // Private but used in tests.
@@ -2249,8 +2225,6 @@
       assertIsDefined(this.replyDialog, 'replyDialog');
       this.replyDialog.open(focusTarget);
     });
-    fireDialogChange(this, {opened: true});
-    this.changeViewAriaHidden = true;
   }
 
   // Private but used in tests.
@@ -2329,14 +2303,8 @@
   }
 
   private startUpdateCheckTimer() {
-    if (
-      !this.serverConfig ||
-      !this.serverConfig.change ||
-      this.serverConfig.change.update_delay === undefined ||
-      this.serverConfig.change.update_delay <= MIN_CHECK_INTERVAL_SECS
-    ) {
-      return;
-    }
+    const delay = this.serverConfig?.change?.update_delay ?? 0;
+    if (delay <= MIN_CHECK_INTERVAL_SECS) return;
 
     this.updateCheckTimerHandle = window.setTimeout(() => {
       if (!this.isViewCurrent || !this.change) {
@@ -2388,7 +2356,7 @@
             callback: () => this.getChangeModel().navigateToChangeResetReload(),
           });
         });
-    }, this.serverConfig.change.update_delay * 1000);
+    }, delay * 1000);
   }
 
   private cancelUpdateCheckTimer() {
@@ -2409,7 +2377,7 @@
   // Private but used in tests.
   computeHeaderClass() {
     const classes = ['header'];
-    if (this.getEditMode()) {
+    if (this.editMode) {
       classes.push('editMode');
     }
     return classes.join(' ');
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 560006b..57df524 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
@@ -60,11 +60,10 @@
 import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
 import {SinonFakeTimers} from 'sinon';
 import {GerritView} from '../../../services/router/router-model';
-import {ParsedChangeInfo} from '../../../types/types';
+import {LoadingStatus, ParsedChangeInfo} from '../../../types/types';
 import {
   ChangeModel,
   changeModelToken,
-  LoadingStatus,
 } from '../../../models/change/change-model';
 import {FocusTarget} from '../gr-reply-dialog/gr-reply-dialog';
 import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
@@ -74,7 +73,7 @@
 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 {ChangeChildView} 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';
@@ -366,7 +365,7 @@
       element,
       /* HTML */ `
         <div class="container loading">Loading...</div>
-        <div aria-hidden="false" class="container" hidden="" id="mainContent">
+        <div class="container" hidden="" id="mainContent">
           <section class="changeInfoSection">
             <div class="header">
               <h1 class="assistive-tech-only">Change :</h1>
@@ -1062,14 +1061,11 @@
     });
   });
 
-  test('reply button is not visible when logged out', async () => {
+  test('reply button is a login button 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');
+    assert.equal(element.replyBtn.textContent, 'Sign in');
   });
 
   test('download tap calls handleOpenDownloadDialog', () => {
@@ -1319,10 +1315,9 @@
     });
   });
 
-  test('header class computation', () => {
+  test('header class computation', async () => {
     assert.equal(element.computeHeaderClass(), 'header');
-    assertIsDefined(element.viewState);
-    element.viewState.edit = true;
+    element.editMode = true;
     assert.equal(element.computeHeaderClass(), 'header editMode');
   });
 
@@ -1343,38 +1338,6 @@
     assert.equal(scrollStub.lastCall.args[0], 'TEST');
   });
 
-  test('computeEditMode', async () => {
-    const callCompute = async (viewState: ChangeViewState) => {
-      element.viewState = viewState;
-      element.patchNum = viewState.patchNum;
-      element.basePatchNum = viewState.basePatchNum ?? PARENT;
-      await element.updateComplete;
-      return element.getEditMode();
-    };
-    assert.isTrue(
-      await callCompute({
-        ...createChangeViewState(),
-        edit: true,
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      })
-    );
-    assert.isFalse(
-      await callCompute({
-        ...createChangeViewState(),
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      })
-    );
-    assert.isTrue(
-      await callCompute({
-        ...createChangeViewState(),
-        basePatchNum: 1 as BasePatchSetNum,
-        patchNum: EDIT,
-      })
-    );
-  });
-
   test('file-action-tap handling', async () => {
     element.patchNum = 1 as RevisionPatchSetNum;
     element.change = {
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 4e60228..1285664 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
@@ -19,6 +19,8 @@
 import {configModelToken} from '../../../models/config/config-model';
 import {createSearchUrl} from '../../../models/views/search';
 import {getBrowseCommitWeblink} from '../../../utils/weblink-util';
+import {shorten} from '../../../utils/patch-set-util';
+import {when} from 'lit/directives/when.js';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -30,7 +32,10 @@
 export class GrCommitInfo extends LitElement {
   // TODO(TS): Maybe limit to StandaloneCommitInfo.
   @property({type: Object})
-  commitInfo?: CommitInfo;
+  commitInfo?: Partial<CommitInfo>;
+
+  @property({type: Boolean})
+  showCopyButton = true;
 
   @state() serverConfig?: ServerInfo;
 
@@ -44,6 +49,9 @@
           align-items: center;
           display: flex;
         }
+        gr-weblink {
+          margin-right: 0;
+        }
       `,
     ];
   }
@@ -62,13 +70,18 @@
     if (!commit) return nothing;
     return html` <div class="container">
       <gr-weblink imageAndText .info=${this.getWeblink(commit)}></gr-weblink>
-      <gr-copy-clipboard
-        hastooltip
-        .buttonTitle=${'Copy full SHA to clipboard'}
-        hideinput
-        .text=${commit}
-      >
-      </gr-copy-clipboard>
+      ${when(
+        this.showCopyButton,
+        () => html`
+          <gr-copy-clipboard
+            hastooltip
+            .buttonTitle=${'Copy full SHA to clipboard'}
+            hideinput
+            .text=${commit}
+          >
+          </gr-copy-clipboard>
+        `
+      )}
     </div>`;
   }
 
@@ -79,12 +92,12 @@
    */
   getWeblink(commit: CommitId): WebLinkInfo | undefined {
     if (!commit) return undefined;
-    const name = commit.slice(0, 7);
+    const name = shorten(commit)!;
     const primaryLink = getBrowseCommitWeblink(
       this.commitInfo?.web_links,
       this.serverConfig
     );
     if (primaryLink) return {...primaryLink, name};
-    return {name, url: createSearchUrl({query: name})};
+    return {name, url: createSearchUrl({query: commit})};
   }
 }
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 6481c26..3fed767 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
@@ -50,7 +50,7 @@
     assert.shadowDom.equal(
       weblink,
       /* HTML */ `
-        <a href="link-url" rel="noopener" target="_blank">
+        <a href="link-url" rel="noopener noreferrer" target="_blank">
           <gr-tooltip-content>
             <span> sha4567 </span>
           </gr-tooltip-content>
@@ -79,7 +79,11 @@
     assert.shadowDom.equal(
       weblink,
       /* HTML */ `
-        <a href="/q/sha4567" rel="noopener" target="_blank">
+        <a
+          href="/q/sha45678901234567890"
+          rel="noopener noreferrer"
+          target="_blank"
+        >
           <gr-tooltip-content>
             <span> sha4567 </span>
           </gr-tooltip-content>
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 2129918..ec34923 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
@@ -15,6 +15,7 @@
 import {ShortcutController} from '../../lit/shortcut-controller';
 import {ChangeActionDialog} from '../../../types/common';
 import {fireNoBubble} from '../../../utils/event-util';
+import {formStyles} from '../../../styles/form-styles';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -61,6 +62,7 @@
 
   static override get styles() {
     return [
+      formStyles,
       sharedStyles,
       css`
         :host {
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 7723327..77a2cf5 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
@@ -27,23 +27,25 @@
    * @event cancel
    */
 
-  static override styles = [
-    sharedStyles,
-    css`
-      :host {
-        display: block;
-      }
-      :host([disabled]) {
-        opacity: 0.5;
-        pointer-events: none;
-      }
-      .main {
-        display: flex;
-        flex-direction: column;
-        width: 100%;
-      }
-    `,
-  ];
+  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%;
+        }
+      `,
+    ];
+  }
 
   override render() {
     return html`
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 891209e..5e4e255 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
@@ -18,6 +18,8 @@
   ChangeInfoId,
   TopicName,
   ChangeActionDialog,
+  EmailInfo,
+  GitPersonInfo,
 } from '../../../types/common';
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {
@@ -30,6 +32,7 @@
   ChangeStatus,
   ProgressStatus,
 } from '../../../constants/constants';
+import {subscribe} from '../../lit/subscription-controller';
 import {fire, fireNoBubble} from '../../../utils/event-util';
 import {css, html, LitElement, PropertyValues} from 'lit';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -40,6 +43,10 @@
 import {createSearchUrl} from '../../../models/views/search';
 import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {uuid} from '../../../utils/common-util';
+import {ParsedChangeInfo} from '../../../types/types';
+import {formStyles} from '../../../styles/form-styles';
+import {branchName} from '../../../utils/patch-set-util';
+import {changeModelToken} from '../../../models/change/change-model';
 
 const SUGGESTIONS_LIMIT = 15;
 const CHANGE_SUBJECT_LIMIT = 50;
@@ -100,7 +107,7 @@
   project?: RepoName;
 
   @property({type: Array})
-  changes: ChangeInfo[] = [];
+  changes: (ParsedChangeInfo | ChangeInfo)[] = [];
 
   @state()
   private query: AutocompleteQuery;
@@ -124,13 +131,24 @@
   @state()
   private invalidBranch = false;
 
+  @state()
+  emails: EmailInfo[] = [];
+
   @query('#branchInput')
   branchInput!: GrTypedAutocomplete<BranchName>;
 
+  @state()
+  committerEmail?: string;
+
+  @state()
+  latestCommitter?: GitPersonInfo;
+
   private selectedChangeIds = new Set<ChangeInfoId>();
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
   private readonly reporting = getAppContext().reportingService;
 
   private readonly getNavigation = resolve(this, navigationToken);
@@ -139,6 +157,16 @@
     super();
     this.statuses = {};
     this.query = (text: string) => this.getProjectBranchesSuggestions(text);
+    subscribe(
+      this,
+      () => this.getChangeModel().latestCommitter$,
+      x => (this.latestCommitter = x)
+    );
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    this.loadEmails();
   }
 
   override willUpdate(changedProperties: PropertyValues) {
@@ -154,75 +182,78 @@
     }
   }
 
-  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;
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      formStyles,
+      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`
@@ -335,6 +366,17 @@
         @bind-value-changed=${(e: BindValueChangeEvent) =>
           (this.message = e.detail.value ?? '')}
       ></iron-autogrow-textarea>
+      ${when(
+        this.canShowEmailDropdown(),
+        () => html`<div id="cherryPickEmailDropdown">Cherry Pick Committer Email
+            <gr-dropdown-list
+                .items=${this.getEmailDropdownItems()}
+                .value=${this.committerEmail}
+                @value-change=${this.setCommitterEmail}
+            >
+            </gr-dropdown-list>
+            <span></div>`
+      )}
     `;
   }
 
@@ -393,7 +435,7 @@
     `;
   }
 
-  containsDuplicateProject(changes: ChangeInfo[]) {
+  containsDuplicateProject(changes: (ChangeInfo | ParsedChangeInfo)[]) {
     const projects: {[projectName: string]: boolean} = {};
     for (let i = 0; i < changes.length; i++) {
       const change = changes[i];
@@ -405,7 +447,7 @@
     return false;
   }
 
-  updateChanges(changes: ChangeInfo[]) {
+  updateChanges(changes: (ParsedChangeInfo | ChangeInfo)[]) {
     this.changes = changes;
     this.statuses = {};
     changes.forEach(change => {
@@ -445,22 +487,31 @@
     return '';
   }
 
-  updateStatus(change: ChangeInfo, status: Status) {
+  updateStatus(change: ChangeInfo | ParsedChangeInfo, status: Status) {
     this.statuses = {...this.statuses, [change.id]: status};
   }
 
-  private computeStatus(change: ChangeInfo, statuses: Statuses) {
+  private computeStatus(
+    change: ChangeInfo | ParsedChangeInfo,
+    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 | ParsedChangeInfo,
+    statuses: Statuses
+  ) {
     if (!change || !statuses || !statuses[change.id]) return '';
     return statuses[change.id].status === ProgressStatus.FAILED ? 'error' : '';
   }
 
-  private computeError(change: ChangeInfo, statuses: Statuses) {
+  private computeError(
+    change: ChangeInfo | ParsedChangeInfo,
+    statuses: Statuses
+  ) {
     if (!change || !statuses || !statuses[change.id]) return '';
     if (statuses[change.id].status === ProgressStatus.FAILED) {
       return statuses[change.id].msg;
@@ -468,7 +519,7 @@
     return '';
   }
 
-  private getChangeId(change: ChangeInfo) {
+  private getChangeId(change: ChangeInfo | ParsedChangeInfo) {
     return change.change_id.substring(0, 10);
   }
 
@@ -532,13 +583,13 @@
     this.message = newMessage;
   }
 
-  private generateRandomCherryPickTopic(change: ChangeInfo) {
+  private generateRandomCherryPickTopic(change: ChangeInfo | ParsedChangeInfo) {
     const message = `cherrypick-${change.topic}-${uuid()}`;
     return message;
   }
 
   private handleCherryPickFailed(
-    change: ChangeInfo,
+    change: ParsedChangeInfo | ChangeInfo,
     response?: Response | null
   ) {
     if (!response) return;
@@ -622,12 +673,9 @@
     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,
+        branchName(input),
         this.project,
         SUGGESTIONS_LIMIT,
         /* offset=*/ undefined,
@@ -637,13 +685,43 @@
         if (!response) return [];
         const branches: Array<{name: BranchName}> = [];
         for (const branchInfo of response) {
-          let name: string = branchInfo.ref;
-          if (name.startsWith('refs/heads/')) {
-            name = name.substring('refs/heads/'.length);
-          }
-          branches.push({name: name as BranchName});
+          branches.push({name: branchName(branchInfo.ref)});
         }
         return branches;
       });
   }
+
+  async loadEmails() {
+    const accountEmails: EmailInfo[] =
+      (await this.restApiService.getAccountEmails()) ?? [];
+    let selectedEmail: string | undefined;
+    accountEmails.forEach(e => {
+      if (e.preferred) {
+        selectedEmail = e.email;
+      }
+    });
+
+    if (accountEmails.some(e => e.email === this.latestCommitter?.email)) {
+      selectedEmail = this.latestCommitter?.email;
+    }
+    this.emails = accountEmails;
+    this.committerEmail = selectedEmail;
+  }
+
+  private canShowEmailDropdown() {
+    return this.emails.length > 1;
+  }
+
+  private getEmailDropdownItems() {
+    return this.emails.map(e => {
+      return {
+        text: e.email,
+        value: e.email,
+      };
+    });
+  }
+
+  private setCommitterEmail(e: CustomEvent<{value: string}>) {
+    this.committerEmail = e.detail.value;
+  }
 }
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
index dc8dba9..83e6f9e 100644
--- 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
@@ -5,7 +5,12 @@
  */
 import '../../../test/common-test-setup';
 import './gr-confirm-cherrypick-dialog';
-import {queryAll, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {
+  query,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
 import {GrConfirmCherrypickDialog} from './gr-confirm-cherrypick-dialog';
 import {
   BranchName,
@@ -24,11 +29,53 @@
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {ProgressStatus} from '../../../constants/constants';
 import {fixture, html, assert} from '@open-wc/testing';
+import {GrDropdownList} from '../../shared/gr-dropdown-list/gr-dropdown-list';
 
 const CHERRY_PICK_TYPES = {
   SINGLE_CHANGE: 1,
   TOPIC: 2,
 };
+
+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,
+  },
+];
+
+const emails = [
+  {
+    email: 'primary@email.com',
+    preferred: true,
+  },
+  {
+    email: 'secondary@email.com',
+    preferred: false,
+  },
+];
+
 suite('gr-confirm-cherrypick-dialog tests', () => {
   let element: GrConfirmCherrypickDialog;
 
@@ -149,34 +196,6 @@
   });
 
   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;
@@ -290,4 +309,36 @@
     assert.equal(branches.length, 1);
     assert.equal(branches[0].name, 'test-branch');
   });
+
+  suite('cherry pick single change with committer email', () => {
+    test('hide email dropdown when user has one email', async () => {
+      element.emails = emails.slice(0, 1);
+      await element.updateComplete;
+      assert.notExists(query(element, '#cherryPickEmailDropdown'));
+    });
+
+    test('show email dropdown when user has more than one email', async () => {
+      element.emails = emails;
+      await element.updateComplete;
+      const cherryPickEmailDropdown = queryAndAssert(
+        element,
+        '#cherryPickEmailDropdown'
+      );
+      assert.dom.equal(
+        cherryPickEmailDropdown,
+        `<div id="cherryPickEmailDropdown">Cherry Pick Committer Email
+        <gr-dropdown-list></gr-dropdown-list>
+        <span></span>
+        </div>`
+      );
+      const emailDropdown = queryAndAssert<GrDropdownList>(
+        cherryPickEmailDropdown,
+        'gr-dropdown-list'
+      );
+      assert.deepEqual(
+        emailDropdown.items?.map(e => e.value),
+        emails.map(e => e.email)
+      );
+    });
+  });
 });
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 6f82e8c..a9bb86e 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
@@ -16,6 +16,8 @@
 import {ShortcutController} from '../../lit/shortcut-controller';
 import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {fireNoBubble} from '../../../utils/event-util';
+import {formStyles} from '../../../styles/form-styles';
+import {branchName} from '../../../utils/patch-set-util';
 
 const SUGGESTIONS_LIMIT = 15;
 
@@ -71,6 +73,7 @@
 
   static override get styles() {
     return [
+      formStyles,
       sharedStyles,
       css`
         :host {
@@ -154,12 +157,9 @@
 
   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,
+        branchName(input),
         this.project,
         SUGGESTIONS_LIMIT,
         /* offest=*/ undefined,
@@ -169,11 +169,7 @@
         if (!response) return [];
         const branches: Array<{name: BranchName}> = [];
         for (const branchInfo of response) {
-          let name: string = branchInfo.ref;
-          if (name.startsWith('refs/heads/')) {
-            name = name.substring('refs/heads/'.length);
-          }
-          branches.push({name: name as BranchName});
+          branches.push({name: branchName(branchInfo.ref)});
         }
         return branches;
       });
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 6ad416e..9eb0805 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
@@ -8,18 +8,20 @@
 import {customElement, property, query, state} from 'lit/decorators.js';
 import {when} from 'lit/directives/when.js';
 import {
-  NumericChangeId,
-  BranchName,
-  ChangeActionDialog,
   AccountDetailInfo,
   AccountInfo,
+  BranchName,
+  ChangeActionDialog,
+  EmailInfo,
+  NumericChangeId,
+  GitPersonInfo,
 } from '../../../types/common';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import {
-  GrAutocomplete,
   AutocompleteQuery,
   AutocompleteSuggestion,
+  GrAutocomplete,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getAppContext} from '../../../services/app-context';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -31,6 +33,7 @@
 import {userModelToken} from '../../../models/user/user-model';
 import {relatedChangesModelToken} from '../../../models/change/related-changes-model';
 import {subscribe} from '../../lit/subscription-controller';
+import {formStyles} from '../../../styles/form-styles';
 
 export interface RebaseChange {
   name: string;
@@ -42,6 +45,7 @@
   allowConflicts: boolean;
   rebaseChain: boolean;
   onBehalfOfUploader: boolean;
+  committerEmail: string | null;
 }
 
 @customElement('gr-confirm-rebase-dialog')
@@ -91,6 +95,18 @@
   @state()
   allowConflicts = false;
 
+  @state()
+  selectedEmailForRebase: string | null | undefined;
+
+  @state()
+  currentUserEmails: EmailInfo[] = [];
+
+  @state()
+  uploaderEmails: EmailInfo[] = [];
+
+  @state()
+  committerEmailDropdownItems: EmailInfo[] = [];
+
   @query('#rebaseOnParentInput')
   private rebaseOnParentInput?: HTMLInputElement;
 
@@ -115,6 +131,9 @@
   @state()
   uploader?: AccountInfo;
 
+  @state()
+  latestCommitter?: GitPersonInfo;
+
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly getChangeModel = resolve(this, changeModelToken);
@@ -149,6 +168,16 @@
       () => this.getRelatedChangesModel().hasParent$,
       x => (this.hasParent = x)
     );
+    subscribe(
+      this,
+      () => this.getChangeModel().latestCommitter$,
+      x => (this.latestCommitter = x)
+    );
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    this.loadCommitterEmailDropdownItems();
   }
 
   override willUpdate(changedProperties: PropertyValues): void {
@@ -160,39 +189,45 @@
     }
   }
 
-  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%;
-      }
-      .rebaseCheckbox {
-        margin-top: var(--spacing-m);
-      }
-      .rebaseOption {
-        margin: var(--spacing-m) 0;
-      }
-      .rebaseOnBehalfMsg {
-        margin-top: var(--spacing-m);
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      formStyles,
+      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%;
+        }
+        .rebaseCheckbox {
+          margin-top: var(--spacing-m);
+        }
+        .rebaseOption {
+          margin: var(--spacing-m) 0;
+        }
+        .rebaseOnBehalfMsg {
+          margin-top: var(--spacing-m);
+        }
+        .rebaseWithCommitterEmail {
+          margin-top: var(--spacing-m);
+        }
+      `,
+    ];
+  }
 
   override render() {
     return html`
@@ -284,6 +319,7 @@
               type="checkbox"
               @change=${() => {
                 this.allowConflicts = !!this.rebaseAllowConflicts?.checked;
+                this.loadCommitterEmailDropdownItems();
               }}
             />
             <label for="rebaseAllowConflicts"
@@ -307,6 +343,9 @@
                   type="checkbox"
                   @change=${() => {
                     this.shouldRebaseChain = !!this.rebaseChain?.checked;
+                    if (this.shouldRebaseChain) {
+                      this.selectedEmailForRebase = undefined;
+                    }
                   }}
                 />
                 <label for="rebaseChain">Rebase all ancestors</label>
@@ -318,10 +357,21 @@
               !this.allowConflicts ? ' the uploader:' : ''
             } <gr-account-chip
                 .account=${this.allowConflicts ? this.account : this.uploader}
-                .hideHovercard=${true}
               ></gr-account-chip
               ><span></div>`
           )}
+          ${when(
+            this.canShowCommitterEmailDropdown(),
+            () => html`<div class="rebaseWithCommitterEmail"
+            >Rebase with committer email
+                <gr-dropdown-list
+                    .items=${this.getCommitterEmailDropdownItems()}
+                    .value=${this.selectedEmailForRebase}
+                    @value-change=${this.handleCommitterEmailDropdownItems}
+                >
+                </gr-dropdown-list>
+                <span></div>`
+          )}
         </div>
       </gr-dialog>
     `;
@@ -374,6 +424,70 @@
     );
   }
 
+  private setPreferredAsSelectedEmailForRebase(emails: EmailInfo[]) {
+    emails.forEach(e => {
+      if (e.preferred) {
+        this.selectedEmailForRebase = e.email;
+      }
+    });
+  }
+
+  private canShowCommitterEmailDropdown() {
+    return (
+      this.committerEmailDropdownItems &&
+      this.committerEmailDropdownItems.length > 1 &&
+      !this.shouldRebaseChain
+    );
+  }
+
+  private getCommitterEmailDropdownItems() {
+    return this.committerEmailDropdownItems?.map(e => {
+      return {
+        text: e.email,
+        value: e.email,
+      };
+    });
+  }
+
+  private isLatestCommitterEmailInDropdownItems(): boolean {
+    return this.committerEmailDropdownItems?.some(
+      e => e.email === this.latestCommitter?.email.toString()
+    );
+  }
+
+  public setSelectedEmailForRebase() {
+    if (this.isLatestCommitterEmailInDropdownItems()) {
+      this.selectedEmailForRebase = this.latestCommitter?.email;
+    } else {
+      this.setPreferredAsSelectedEmailForRebase(
+        this.committerEmailDropdownItems
+      );
+    }
+  }
+
+  async loadCommitterEmailDropdownItems() {
+    if (this.isCurrentUserEqualToLatestUploader() || this.allowConflicts) {
+      const currentUserEmails = await this.restApiService.getAccountEmails();
+      this.committerEmailDropdownItems = currentUserEmails || [];
+    } else if (this.uploader && this.uploader.email) {
+      const currentUploaderEmails =
+        await this.restApiService.getAccountEmailsFor(
+          this.uploader.email.toString(),
+          () => {}
+        );
+      this.committerEmailDropdownItems = currentUploaderEmails || [];
+    } else {
+      this.committerEmailDropdownItems = [];
+    }
+    if (this.committerEmailDropdownItems) {
+      this.setSelectedEmailForRebase();
+    }
+  }
+
+  private handleCommitterEmailDropdownItems(e: CustomEvent<{value: string}>) {
+    this.selectedEmailForRebase = e.detail.value;
+  }
+
   filterChanges(
     input: string,
     changes: RebaseChange[]
@@ -433,6 +547,9 @@
       allowConflicts: !!this.rebaseAllowConflicts?.checked,
       rebaseChain: !!this.rebaseChain?.checked,
       onBehalfOfUploader: this.rebaseOnBehalfOfUploader(),
+      committerEmail: this.rebaseChain?.checked
+        ? null
+        : this.selectedEmailForRebase || null,
     };
     fireNoBubbleNoCompose(this, 'confirm-rebase', detail);
     this.text = '';
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 24f8a34..038fcd5 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
@@ -8,11 +8,18 @@
 import {GrConfirmRebaseDialog, RebaseChange} from './gr-confirm-rebase-dialog';
 import {
   pressKey,
+  query,
   queryAndAssert,
   stubRestApi,
   waitUntil,
 } from '../../../test/test-utils';
-import {NumericChangeId, BranchName, Timestamp} from '../../../types/common';
+import {
+  NumericChangeId,
+  BranchName,
+  Timestamp,
+  AccountId,
+  EmailAddress,
+} from '../../../types/common';
 import {
   createAccountWithEmail,
   createChangeViewChange,
@@ -22,11 +29,10 @@
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {testResolver} from '../../../test/common-test-setup';
 import {userModelToken} from '../../../models/user/user-model';
-import {
-  changeModelToken,
-  LoadingStatus,
-} from '../../../models/change/change-model';
+import {changeModelToken} from '../../../models/change/change-model';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
+import {LoadingStatus} from '../../../types/types';
+import {GrDropdownList} from '../../shared/gr-dropdown-list/gr-dropdown-list';
 
 suite('gr-confirm-rebase-dialog tests', () => {
   let element: GrConfirmRebaseDialog;
@@ -159,6 +165,131 @@
     });
   });
 
+  suite('rebase with committer email', () => {
+    setup(async () => {
+      element.branch = 'test' as BranchName;
+      await element.updateComplete;
+    });
+
+    test('hide rebaseWithCommitterEmail dialog when committer has single email', async () => {
+      element.committerEmailDropdownItems = [
+        {
+          email: 'test1@example.com',
+          preferred: true,
+          pending_confirmation: true,
+        },
+      ];
+      await element.updateComplete;
+      assert.isNotOk(query(element, '.rebaseWithCommitterEmail'));
+    });
+
+    test('show rebaseWithCommitterEmail dialog when committer has more than one email', async () => {
+      element.committerEmailDropdownItems = [
+        {
+          email: 'test1@example.com',
+          preferred: true,
+          pending_confirmation: true,
+        },
+        {
+          email: 'test2@example.com',
+          pending_confirmation: true,
+        },
+      ];
+      await element.updateComplete;
+      const committerEmail = queryAndAssert(
+        element,
+        '.rebaseWithCommitterEmail'
+      );
+      assert.dom.equal(
+        committerEmail,
+        /* HTML */ `<div class="rebaseWithCommitterEmail"
+              >Rebase with committer email
+              <gr-dropdown-list>
+              </gr-dropdown-list>
+              <span></div>`
+      );
+      const dropdownList: GrDropdownList = queryAndAssert(
+        committerEmail,
+        'gr-dropdown-list'
+      );
+      assert.strictEqual(dropdownList.items!.length, 2);
+    });
+
+    test('hide rebaseWithCommitterEmail dialog when RebaseChain is set', async () => {
+      element.shouldRebaseChain = true;
+      await element.updateComplete;
+      assert.isNotOk(query(element, '.rebaseWithCommitterEmail'));
+    });
+
+    test('show current user emails in the dropdown list when rebase with conflicts is allowed', async () => {
+      element.allowConflicts = true;
+      element.latestCommitter = {
+        email: 'commit@example.com' as EmailAddress,
+        name: 'committer',
+        date: '2023-06-12 18:32:08.000000000' as Timestamp,
+      };
+      element.committerEmailDropdownItems = [
+        {
+          email: 'currentuser1@example.com',
+          preferred: true,
+          pending_confirmation: true,
+        },
+        {
+          email: 'currentuser2@example.com',
+          pending_confirmation: true,
+        },
+      ];
+      await element.updateComplete;
+      const committerEmail = queryAndAssert(
+        element,
+        '.rebaseWithCommitterEmail'
+      );
+      const dropdownList: GrDropdownList = queryAndAssert(
+        committerEmail,
+        'gr-dropdown-list'
+      );
+      assert.deepStrictEqual(
+        dropdownList.items!.map(e => e.value),
+        element.committerEmailDropdownItems.map(e => e.email)
+      );
+    });
+
+    test('show uploader emails in the dropdown list when rebase with conflicts is not allowed', async () => {
+      element.allowConflicts = false;
+      element.uploader = {_account_id: 2 as AccountId, name: '2'};
+      element.latestCommitter = {
+        email: 'commit@example.com' as EmailAddress,
+        name: 'committer',
+        date: '2023-06-12 18:32:08.000000000' as Timestamp,
+      };
+      element.committerEmailDropdownItems = [
+        {
+          email: 'uploader1@example.com',
+          preferred: true,
+          pending_confirmation: true,
+        },
+        {
+          email: 'uploader2@example.com',
+          preferred: false,
+          pending_confirmation: true,
+        },
+      ];
+      await element.updateComplete;
+      const committerEmail = queryAndAssert(
+        element,
+        '.rebaseWithCommitterEmail'
+      );
+      const dropdownList: GrDropdownList = queryAndAssert(
+        committerEmail,
+        'gr-dropdown-list'
+      );
+      assert.deepStrictEqual(
+        dropdownList.items!.map(e => e.value),
+        element.committerEmailDropdownItems.map(e => e.email)
+      );
+    });
+  });
+
   test('disableActions property disables dialog confirm', async () => {
     element.disableActions = false;
     await element.updateComplete;
@@ -258,6 +389,49 @@
     );
   });
 
+  test('committer email is sent when chain is not rebased', async () => {
+    const fireStub = sinon.stub(element, 'dispatchEvent');
+    element.text = '123';
+    element.selectedEmailForRebase = 'abc@def.com';
+    await element.updateComplete;
+    queryAndAssert(element, '#confirmDialog').dispatchEvent(
+      new CustomEvent('confirm', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+    assert.deepEqual((fireStub.lastCall.args[0] as CustomEvent).detail, {
+      allowConflicts: false,
+      base: '123',
+      rebaseChain: false,
+      onBehalfOfUploader: true,
+      committerEmail: 'abc@def.com',
+    });
+  });
+
+  test('committer email is not sent when chain is rebased', async () => {
+    const fireStub = sinon.stub(element, 'dispatchEvent');
+    element.text = '123';
+    element.selectedEmailForRebase = 'abc@def.com';
+    element.hasParent = true;
+    element.shouldRebaseChain = true;
+    await element.updateComplete;
+    queryAndAssert<HTMLInputElement>(element, '#rebaseChain').checked = true;
+    queryAndAssert(element, '#confirmDialog').dispatchEvent(
+      new CustomEvent('confirm', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+    assert.deepEqual((fireStub.lastCall.args[0] as CustomEvent).detail, {
+      allowConflicts: false,
+      base: '123',
+      rebaseChain: true,
+      onBehalfOfUploader: true,
+      committerEmail: null,
+    });
+  });
+
   test('input cleared on cancel or submit', async () => {
     element.text = '123';
     await element.updateComplete;
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 fedc377..5131083 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
@@ -15,6 +15,8 @@
 import {resolve} from '../../../models/dependency';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {createSearchUrl} from '../../../models/views/search';
+import {ParsedChangeInfo} from '../../../types/types';
+import {formStyles} from '../../../styles/form-styles';
 
 const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
 const INSERT_REASON_STRING = '<INSERT REASONING HERE>';
@@ -65,43 +67,46 @@
 
   private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
-  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);
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      formStyles,
+      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`
@@ -170,15 +175,23 @@
     return this.revertType === RevertType.REVERT_SUBMISSION;
   }
 
-  modifyRevertMsg(change: ChangeInfo, commitMessage: string, message: string) {
+  modifyRevertMsg(
+    change: ParsedChangeInfo,
+    commitMessage: string,
+    message: string
+  ) {
     return this.getPluginLoader().jsApiService.modifyRevertMsg(
-      change,
+      change as ChangeInfo,
       message,
       commitMessage
     );
   }
 
-  populate(change: ChangeInfo, commitMessage: string, changesCount: number) {
+  populate(
+    change: ParsedChangeInfo,
+    commitMessage: string,
+    changesCount: number
+  ) {
     this.changesCount = changesCount;
     // The option to revert a single change is always available
     this.populateRevertSingleChangeMessage(
@@ -190,13 +203,22 @@
   }
 
   populateRevertSingleChangeMessage(
-    change: ChangeInfo,
+    change: ParsedChangeInfo,
     commitMessage: string,
     commitHash?: CommitId
   ) {
     // Figure out what the revert title should be.
     const originalTitle = (commitMessage || '').split('\n')[0];
-    const revertTitle = `Revert "${originalTitle}"`;
+    let revertTitle = `Revert "${originalTitle}"`;
+    const match = originalTitle.match(/^Revert(?:\^([0-9]+))? "(.*)"$/);
+    if (match) {
+      let revertNum = 2;
+      if (match[1]) {
+        revertNum = Number(match[1]) + 1;
+      }
+      revertTitle = `Revert^${revertNum} "${match[2]}"`;
+    }
+
     if (!commitHash) {
       fireAlert(this, ERR_COMMIT_NOT_FOUND);
       return;
@@ -215,18 +237,21 @@
   }
 
   private modifyRevertSubmissionMsg(
-    change: ChangeInfo,
+    change: ParsedChangeInfo,
     msg: string,
     commitMessage: string
   ) {
     return this.getPluginLoader().jsApiService.modifyRevertSubmissionMsg(
-      change,
+      change as ChangeInfo,
       msg,
       commitMessage
     );
   }
 
-  populateRevertSubmissionMessage(change: ChangeInfo, commitMessage: string) {
+  populateRevertSubmissionMessage(
+    change: ParsedChangeInfo,
+    commitMessage: string
+  ) {
     // Follow the same convention of the revert
     const commitHash = change.current_revision;
     if (!commitHash) {
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 904285f..920ff00 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
@@ -5,7 +5,7 @@
  */
 import {fixture, html, assert} from '@open-wc/testing';
 import '../../../test/common-test-setup';
-import {createChange} from '../../../test/test-data-generators';
+import {createParsedChange} from '../../../test/test-data-generators';
 import {ChangeSubmissionId, CommitId} from '../../../types/common';
 import './gr-confirm-revert-dialog';
 import {GrConfirmRevertDialog} from './gr-confirm-revert-dialog';
@@ -48,7 +48,7 @@
     const alertStub = sinon.stub();
     element.addEventListener('show-alert', alertStub);
     element.populateRevertSingleChangeMessage(
-      createChange(),
+      createParsedChange(),
       'not a commitHash in sight',
       undefined
     );
@@ -58,7 +58,7 @@
   test('single line', () => {
     assert.isNotOk(element.message);
     element.populateRevertSingleChangeMessage(
-      createChange(),
+      createParsedChange(),
       'one line commit\n\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
     );
@@ -72,7 +72,7 @@
   test('multi line', () => {
     assert.isNotOk(element.message);
     element.populateRevertSingleChangeMessage(
-      createChange(),
+      createParsedChange(),
       'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
     );
@@ -86,7 +86,7 @@
   test('issue above change id', () => {
     assert.isNotOk(element.message);
     element.populateRevertSingleChangeMessage(
-      createChange(),
+      createParsedChange(),
       'much lines\nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
     );
@@ -100,12 +100,26 @@
   test('revert a revert', () => {
     assert.isNotOk(element.message);
     element.populateRevertSingleChangeMessage(
-      createChange(),
+      createParsedChange(),
       'Revert "one line commit"\n\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
     );
     const expected =
-      'Revert "Revert "one line commit""\n\n' +
+      'Revert^2 "one line commit"\n\n' +
+      'This reverts commit abcd123.\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element.message, expected);
+  });
+
+  test('revert a revert of a revert', () => {
+    assert.isNotOk(element.message);
+    element.populateRevertSingleChangeMessage(
+      createParsedChange(),
+      'Revert^2 "one line commit"\n\nChange-Id: abcdefg\n',
+      'abcd123' as CommitId
+    );
+    const expected =
+      'Revert^3 "one line commit"\n\n' +
       'This reverts commit abcd123.\n\n' +
       'Reason for revert: <INSERT REASONING HERE>\n';
     assert.equal(element.message, expected);
@@ -115,7 +129,7 @@
     element.changesCount = 3;
     element.populateRevertSubmissionMessage(
       {
-        ...createChange(),
+        ...createParsedChange(),
         submission_id: '5545' as ChangeSubmissionId,
         current_revision: 'abcd123' as CommitId,
       },
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
index 4858a31..bf56890 100644
--- 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
@@ -7,11 +7,14 @@
 import '@polymer/iron-dropdown/iron-dropdown';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import {LitElement, html, css, nothing} from 'lit';
+import {Ref, createRef, ref} from 'lit/directives/ref.js';
 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';
+import {formStyles} from '../../../styles/form-styles';
+import {GrCopyClipboard} from '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 
 export interface CopyLink {
   label: string;
@@ -24,6 +27,8 @@
 
 @customElement('gr-copy-links')
 export class GrCopyLinks extends LitElement {
+  copyClipboardRef: Ref<GrCopyClipboard> = createRef();
+
   @property({type: Array})
   copyLinks: CopyLink[] = [];
 
@@ -33,6 +38,7 @@
 
   static override get styles() {
     return [
+      formStyles,
       css`
         iron-dropdown {
           box-shadow: var(--elevation-level-2);
@@ -45,35 +51,10 @@
         }
         .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 {
+        gr-copy-clipboard::part(text-container-wrapper-style) {
           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;
-        }
       `,
     ];
   }
@@ -94,23 +75,23 @@
 
   private renderCopyLinks() {
     return html`<div slot="dropdown-content">
-      ${this.copyLinks?.map(link => this.renderCopyLinkRow(link))}
+      ${this.copyLinks?.map((link, index) =>
+        this.renderCopyLinkRow(link, index)
+      )}
     </div>`;
   }
 
-  private renderCopyLinkRow(copyLink: CopyLink) {
+  private renderCopyLinkRow(copyLink: CopyLink, index?: number) {
     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}
+        label=${label}
+        shortcut=${`l - ${shortcut}`}
         id=${`${id}-copy-clipboard`}
+        nowrap
+        ${index === 0 && ref(this.copyClipboardRef)}
       ></gr-copy-clipboard>
     </div>`;
   }
@@ -133,7 +114,11 @@
   openDropdown() {
     this.dropdown?.open();
     this.awaitOpen(() => {
-      queryAndAssert<HTMLInputElement>(this.dropdown, 'input')?.select();
+      if (!this.copyClipboardRef?.value) return;
+      queryAndAssert<HTMLInputElement>(
+        this.copyClipboardRef.value,
+        'input'
+      )?.select();
     });
   }
 
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
index 4a0742b..37dd9aa 100644
--- 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
@@ -41,15 +41,8 @@
       >
       <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 label="Change ID" nowrap="" shortcut="l - d"
+                text="123456" id="Change_ID-field-copy-clipboard">
             </gr-copy-clipboard>
           </div>
       </iron-dropdown>`,
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 11dc890..a4c52d7 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
@@ -5,7 +5,7 @@
  */
 import '../../shared/gr-download-commands/gr-download-commands';
 import {changeBaseURL, getRevisionKey} from '../../../utils/change-util';
-import {ChangeInfo, DownloadInfo, PatchSetNum} from '../../../types/common';
+import {DownloadInfo, PatchSetNum} from '../../../types/common';
 import {GrDownloadCommands} from '../../shared/gr-download-commands/gr-download-commands';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {copyToClipbard, hasOwnProperty} from '../../../utils/common-util';
@@ -13,13 +13,16 @@
 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 {customElement, state, query} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
 import {BindValueChangeEvent} from '../../../types/events';
 import {ShortcutController} from '../../lit/shortcut-controller';
 import {subscribe} from '../../lit/subscription-controller';
 import {resolve} from '../../../models/dependency';
 import {changeModelToken} from '../../../models/change/change-model';
+import {ParsedChangeInfo} from '../../../types/types';
+import {configModelToken} from '../../../models/config/config-model';
+import {shorten} from '../../../utils/patch-set-util';
 
 @customElement('gr-download-dialog')
 export class GrDownloadDialog extends LitElement {
@@ -35,27 +38,37 @@
 
   @query('#closeButton') protected closeButton?: GrButton;
 
-  @property({type: Object})
-  change: ChangeInfo | undefined;
+  @state() change?: ParsedChangeInfo;
 
-  @property({type: Object})
-  config?: DownloadInfo;
+  @state() config?: DownloadInfo;
 
   @state() patchNum?: PatchSetNum;
 
-  @state() private selectedScheme?: string;
+  @state() selectedScheme?: string;
 
   private readonly shortcuts = new ShortcutController(this);
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
+  private readonly getConfigModel = resolve(this, configModelToken);
+
   constructor() {
     super();
     subscribe(
       this,
+      () => this.getChangeModel().change$,
+      x => (this.change = x)
+    );
+    subscribe(
+      this,
       () => this.getChangeModel().patchNum$,
       x => (this.patchNum = x)
     );
+    subscribe(
+      this,
+      () => this.getConfigModel().download$,
+      x => (this.config = x)
+    );
     for (const key of ['1', '2', '3', '4', '5']) {
       this.shortcuts.addLocal({key}, e => this.handleNumberKey(e));
     }
@@ -284,9 +297,7 @@
     }
 
     const rev = getRevisionKey(this.change, this.patchNum) ?? '';
-    const shortRev = rev.substr(0, 7);
-
-    return shortRev + '.diff.' + (zip ? 'zip' : 'base64');
+    return shorten(rev)! + '.diff.' + (zip ? 'zip' : 'base64');
   }
 
   // private but used in test
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 e5f40a6..73d7618 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
@@ -8,6 +8,7 @@
   createChange,
   createCommit,
   createDownloadInfo,
+  createParsedChange,
   createRevision,
 } from '../../../test/test-data-generators';
 import {
@@ -160,7 +161,7 @@
   suite('gr-download-dialog tests with no fetch options', () => {
     setup(async () => {
       element.change = {
-        ...createChange(),
+        ...createParsedChange(),
         revisions: {
           r1: {
             ...createRevision(),
@@ -204,7 +205,7 @@
 
     test('computed fields', () => {
       element.change = {
-        ...createChange(),
+        ...createParsedChange(),
         project: 'test/project' as RepoName,
         _number: 123 as NumericChangeId,
       };
@@ -233,7 +234,7 @@
     element.patchNum = 1 as PatchSetNum;
 
     element.change = {
-      ...createChange(),
+      ...createParsedChange(),
       revisions: {
         r1: {...createRevision(), commit: createCommit()},
       },
@@ -241,7 +242,7 @@
     assert.isTrue(element.computeHidePatchFile());
 
     element.change = {
-      ...createChange(),
+      ...createParsedChange(),
       revisions: {
         r1: {
           ...createRevision(),
@@ -255,7 +256,7 @@
     assert.isFalse(element.computeHidePatchFile());
 
     element.change = {
-      ...createChange(),
+      ...createParsedChange(),
       revisions: {
         r1: {
           ...createRevision(),
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 fd3ddac..f939374 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
@@ -3,7 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
+import '../../diff/gr-diff-mode-selector/gr-diff-mode-selector';
 import '../../diff/gr-patch-range-select/gr-patch-range-select';
 import '../../edit/gr-edit-controls/gr-edit-controls';
 import '../../shared/gr-select/gr-select';
@@ -23,7 +23,7 @@
   PatchSetNumber,
 } from '../../../types/common';
 import {DiffPreferencesInfo} from '../../../types/diff';
-import {GrDiffModeSelector} from '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
+import {GrDiffModeSelector} from '../../diff/gr-diff-mode-selector/gr-diff-mode-selector';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {fire, fireNoBubbleNoCompose} from '../../../utils/event-util';
 import {css, html, LitElement, nothing} from 'lit';
@@ -139,99 +139,100 @@
     );
   }
 
-  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;
-      }
-      .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;
-      }
-      .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 {
+  static override get styles() {
+    return [
+      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;
+        }
+        .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 {
+          display: none;
+        }
+        .someExpanded #expandBtn {
+          margin-right: 8px;
+        }
+        .someExpanded #collapseBtn,
+        .allExpanded #collapseBtn {
+          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;
+        }
+        .fileViewActions,
+        .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) {
@@ -373,10 +374,8 @@
   private computeExpandedClass(filesExpanded?: FilesExpandedState) {
     const classes = [];
     if (filesExpanded === FilesExpandedState.ALL) {
-      classes.push('openFile');
       classes.push('allExpanded');
     } else if (filesExpanded === FilesExpandedState.SOME) {
-      classes.push('openFile');
       classes.push('someExpanded');
     }
     return classes.join(' ');
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
index e2c3304..e6005c2 100644
--- 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
@@ -185,20 +185,6 @@
     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.
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 fcb9bbf..63f5d4f 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
@@ -5,7 +5,6 @@
  */
 import '../../../styles/gr-a11y-styles';
 import '../../../styles/shared-styles';
-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';
@@ -16,6 +15,7 @@
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../../shared/gr-file-status/gr-file-status';
+import '../gr-comments-summary/gr-comments-summary';
 import {assertIsDefined} from '../../../utils/common-util';
 import {asyncForeach} from '../../../utils/async-util';
 import {FilesExpandedState} from '../gr-file-list-constants';
@@ -28,7 +28,7 @@
   ScrollMode,
   SpecialFilePath,
 } from '../../../constants/constants';
-import {descendedFromClass, Key, toggleClass} from '../../../utils/dom-util';
+import {descendedFromClass, Key} from '../../../utils/dom-util';
 import {
   computeDisplayPath,
   computeTruncatedPath,
@@ -126,7 +126,7 @@
   maxDeleted: number;
   maxAdditionWidth: number;
   maxDeletionWidth: number;
-  deletionOffset: number;
+  additionOffset: number;
 }
 
 function createDefaultSizeBarLayout(): SizeBarLayout {
@@ -137,7 +137,7 @@
     maxDeleted: 0,
     maxAdditionWidth: 0,
     maxDeletionWidth: 0,
-    deletionOffset: 0,
+    additionOffset: 0,
   };
 }
 
@@ -246,18 +246,12 @@
   @state()
   diffPrefs?: DiffPreferencesInfo;
 
-  @state() numFilesShown = DEFAULT_NUM_FILES_SHOWN;
+  @state() numFilesShown = 0;
 
   @state()
   fileListIncrement: number = DEFAULT_NUM_FILES_SHOWN;
 
   // Private but used in tests.
-  shownFiles: NormalizedFileInfo[] = [];
-
-  @state()
-  private reportinShownFilesIncrement = 0;
-
-  // Private but used in tests.
   @state()
   expandedFiles: PatchSetFile[] = [];
 
@@ -422,7 +416,6 @@
           text-align: center;
         }
         .path {
-          cursor: pointer;
           flex: 1;
           /* Wrap it into multiple lines if too long. */
           white-space: normal;
@@ -466,13 +459,13 @@
         }
         .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);
         }
+        .removed {
+          color: var(--negative-red-text-color);
+        }
         .drafts {
           color: var(--error-foreground);
           font-weight: var(--font-weight-bold);
@@ -673,7 +666,7 @@
     );
     this.shortcutsController.addAbstract(
       Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
-      _ => toggleClass(this, 'hideComments')
+      _ => this.classList.toggle('hideComments')
     );
     this.shortcutsController.addAbstract(
       Shortcut.CURSOR_NEXT_FILE,
@@ -837,16 +830,15 @@
     }
     if (changedProperties.has('files')) {
       this.filesChanged();
-    }
-    if (
-      changedProperties.has('files') ||
-      changedProperties.has('numFilesShown')
-    ) {
-      this.shownFiles = this.computeFilesShown();
+      this.numFilesShown = Math.min(this.files.length, DEFAULT_NUM_FILES_SHOWN);
+      fire(this, 'files-shown-changed', {length: this.numFilesShown});
     }
     if (changedProperties.has('expandedFiles')) {
       this.expandedFilesChanged(changedProperties.get('expandedFiles'));
     }
+    if (changedProperties.has('numFilesShown')) {
+      fire(this, 'files-shown-changed', {length: this.numFilesShown});
+    }
   }
 
   override connectedCallback() {
@@ -1006,7 +998,10 @@
           class="extra-col"
           .name=${headerEndpoint}
           role="columnheader"
-        ></gr-endpoint-decorator>
+        >
+          <gr-endpoint-param name="change" .value=${this.change}>
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
       `
     );
   }
@@ -1018,7 +1013,7 @@
     const sizeBarLayout = this.computeSizeBarLayout();
 
     return incrementalRepeat({
-      values: this.shownFiles,
+      values: this.files,
       mapFn: (f, i) =>
         this.renderFileRow(
           f as NormalizedFileInfo,
@@ -1029,6 +1024,8 @@
         ),
       initialCount: this.fileListIncrement,
       targetFrameRate: 1,
+      startAt: 0,
+      endAt: this.numFilesShown,
     });
   }
 
@@ -1039,8 +1036,7 @@
     showDynamicColumns: boolean,
     showPrependedDynamicColumns: boolean
   ) {
-    this.reportRenderedRow(index);
-    const previousFileName = this.shownFiles[index - 1]?.__path;
+    const previousFileName = this.files[index - 1]?.__path;
     const patchSetFile = this.computePatchSetFile(file);
     return html` <div class="stickyArea">
       <div
@@ -1317,19 +1313,19 @@
       <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)}
+            x=${this.computeBarDeletionX(file, sizeBarLayout)}
             y="0"
             height="8"
             fill="var(--negative-red-text-color)"
             width=${this.computeBarDeletionWidth(file, sizeBarLayout)}
           ></rect>
+          <rect
+            x=${this.computeBarAdditionX(sizeBarLayout)}
+            y="0"
+            height="8"
+            fill="var(--positive-green-text-color)"
+            width=${this.computeBarAdditionWidth(file, sizeBarLayout)}
+          ></rect>
         </svg>
       </div>
     </div>`;
@@ -1344,14 +1340,6 @@
         -->
       <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`}
@@ -1360,6 +1348,14 @@
           -${file.lines_deleted}
         </span>
         <span
+          class="added"
+          tabindex="0"
+          aria-label=${`${file.lines_inserted} added`}
+          ?hidden=${file.binary}
+        >
+          +${file.lines_inserted}
+        </span>
+        <span
           class=${ifDefined(this.computeBinaryClass(file.size_delta))}
           ?hidden=${!file.binary}
         >
@@ -1538,19 +1534,19 @@
         <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>
+            <span
+              class="added"
+              tabindex="0"
+              aria-label="Total ${patchChange.inserted} lines added"
+            >
+              +${patchChange.inserted}
+            </span>
           </div>
         </div>
         ${when(showDynamicColumns, () =>
@@ -1582,16 +1578,6 @@
       <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}"
           >
@@ -1601,6 +1587,16 @@
               patchChange.size_delta_deleted
             )}
           </span>
+          <span
+            class="added"
+            aria-label="Total bytes inserted: ${deltaInserted}"
+          >
+            ${deltaInserted}
+            ${this.formatPercentage(
+              patchChange.total_size,
+              patchChange.size_delta_inserted
+            )}
+          </span>
         </div>
       </div>
     `;
@@ -1794,10 +1790,10 @@
     // expanded list.
     const newFiles: PatchSetFile[] = [];
     let path: string;
-    for (let i = 0; i < this.shownFiles.length; i++) {
-      path = this.shownFiles[i].__path;
+    for (let i = 0; i < this.numFilesShown; i++) {
+      path = this.files[i].__path;
       if (!this.expandedFiles.some(f => f.path === path)) {
-        newFiles.push(this.computePatchSetFile(this.shownFiles[i]));
+        newFiles.push(this.computePatchSetFile(this.files[i]));
       }
     }
 
@@ -1809,37 +1805,6 @@
   }
 
   /**
-   * Computes a string with the number of comments and unresolved comments.
-   */
-  computeCommentsString(file?: NormalizedFileInfo) {
-    if (
-      this.changeComments === undefined ||
-      this.patchRange === undefined ||
-      file?.__path === undefined
-    ) {
-      return '';
-    }
-    return this.changeComments.computeCommentsString(
-      this.patchRange,
-      file.__path,
-      file
-    );
-  }
-
-  /**
-   * Computes a string with the number of drafts.
-   */
-  computeDraftsString(file?: NormalizedFileInfo) {
-    if (this.changeComments === undefined) return '';
-    const draftCount = this.changeComments.computeDraftCountForFile(
-      this.patchRange,
-      file
-    );
-    if (draftCount === 0) return '';
-    return pluralize(Number(draftCount), 'draft');
-  }
-
-  /**
    * Computes a shortened string with the number of drafts.
    * Private but used in tests.
    */
@@ -2251,26 +2216,6 @@
     );
   }
 
-  private computeFilesShown(): NormalizedFileInfo[] {
-    const previousNumFilesShown = this.shownFiles ? this.shownFiles.length : 0;
-
-    const filesShown = this.files.slice(0, this.numFilesShown);
-    fire(this, 'files-shown-changed', {length: filesShown.length});
-
-    // 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(
-      0,
-      filesShown.length - previousNumFilesShown
-    );
-
-    return filesShown;
-  }
-
   // Private but used in tests.
   updateDiffCursor() {
     // Overwrite the cursor's list of diffs:
@@ -2290,6 +2235,9 @@
 
   private incrementNumFilesShown() {
     this.numFilesShown += this.fileListIncrement;
+    if (this.numFilesShown > this.files.length) {
+      this.numFilesShown = this.files.length;
+    }
   }
 
   private computeFileListControlClass() {
@@ -2363,13 +2311,6 @@
    * Private but used in tests.
    */
   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
-    );
-    this.clearCollapsedDiffs(collapsedDiffs);
-
     this.filesExpanded = this.computeExpandedFiles();
 
     const newFiles = this.expandedFiles.filter(
@@ -2386,14 +2327,6 @@
     this.diffCursor?.reInitAndUpdateStops();
   }
 
-  // private but used in test
-  clearCollapsedDiffs(collapsedDiffs: GrDiffHost[]) {
-    for (const diff of collapsedDiffs) {
-      diff.cancel();
-      diff.clearDiffContent();
-    }
-  }
-
   /**
    * Given an array of paths and a NodeList of diff elements, render the diff
    * for each path in order, awaiting the previous render to complete before
@@ -2476,7 +2409,6 @@
     if (this.cancelForEachDiff) {
       this.cancelForEachDiff();
     }
-    this.forEachDiff(d => d.cancel());
   }
 
   /**
@@ -2497,7 +2429,8 @@
    */
   computeSizeBarLayout() {
     const stats: SizeBarLayout = createDefaultSizeBarLayout();
-    this.shownFiles
+    this.files
+      .slice(0, this.numFilesShown)
       .filter(f => !isMagicPath(f.__path))
       .forEach(f => {
         if (f.lines_inserted) {
@@ -2513,7 +2446,7 @@
         (SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH) * ratio;
       stats.maxDeletionWidth =
         SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH - stats.maxAdditionWidth;
-      stats.deletionOffset = stats.maxAdditionWidth + SIZE_BAR_GAP_WIDTH;
+      stats.additionOffset = stats.maxDeletionWidth + SIZE_BAR_GAP_WIDTH;
     }
     return stats;
   }
@@ -2541,9 +2474,8 @@
    * Get the x-offset of the addition bar for a file.
    * Private but used in tests.
    */
-  computeBarAdditionX(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
-    if (!file || !stats) return;
-    return stats.maxAdditionWidth - this.computeBarAdditionWidth(file, stats);
+  computeBarAdditionX(stats: SizeBarLayout) {
+    return stats.additionOffset;
   }
 
   /**
@@ -2567,9 +2499,11 @@
 
   /**
    * Get the x-offset of the deletion bar for a file.
+   * Private but used in tests.
    */
-  private computeBarDeletionX(stats: SizeBarLayout) {
-    return stats.deletionOffset;
+  computeBarDeletionX(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+    if (!file || !stats) return;
+    return stats.maxDeletionWidth - this.computeBarDeletionWidth(file, stats);
   }
 
   // Private but used in tests.
@@ -2623,24 +2557,6 @@
     return this.filesExpanded === FilesExpandedState.NONE;
   }
 
-  /**
-   * Method to call via binding when each file list row is rendered. This
-   * allows approximate detection of when the dom-repeat has completed
-   * rendering.
-   *
-   * @param index The index of the row being rendered.
-   * Private but used in tests.
-   */
-  reportRenderedRow(index: number) {
-    if (index === this.shownFiles.length - 1) {
-      setTimeout(() => {
-        this.reporting.timeEnd(Timing.FILE_RENDER, {
-          count: this.reportinShownFilesIncrement,
-        });
-      }, 1);
-    }
-  }
-
   private getOldPath(file: NormalizedFileInfo) {
     // The gr-endpoint-decorator is waiting until all gr-endpoint-param
     // values are updated.
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
index 6033a25..0f9cf6a 100644
--- 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
@@ -57,6 +57,8 @@
 import {Modifier} from '../../../utils/dom-util';
 import {testResolver} from '../../../test/common-test-setup';
 import {FileMode} from '../../../utils/file-util';
+import {SinonStubbedMember} from 'sinon';
+import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
 
 suite('gr-diff a11y test', () => {
   test('audit', async () => {
@@ -74,7 +76,6 @@
 
 suite('gr-file-list tests', () => {
   let element: GrFileList;
-
   let saveStub: sinon.SinonStub;
 
   suite('basic tests', async () => {
@@ -104,11 +105,9 @@
       element.numFilesShown = 200;
       element.basePatchNum = PARENT;
       element.patchNum = 2 as RevisionPatchSetNum;
-      saveStub = sinon
-        .stub(element, '_saveReviewedState')
-        .callsFake(() => Promise.resolve());
-      await element.updateComplete;
+      saveStub = sinon.stub(element, '_saveReviewedState').resolves();
       element.showSizeBars = true;
+      await element.updateComplete;
       // Wait for expandedFilesChanged to complete.
       await waitEventLoop();
     });
@@ -205,14 +204,16 @@
             </div>
           </div>
           <div class="desktop" role="gridcell">
-            <div aria-hidden="true" class="sizeBars"></div>
+            <div aria-hidden="true" class="sizeBars">
+              <svg><!-- contents asserted separately below --></svg>
+            </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 aria-label="9 added" class="added" tabindex="0"> +9 </span>
               <span hidden=""> +/-0 B </span>
             </div>
           </div>
@@ -260,6 +261,31 @@
           </div>
         </div>`
       );
+      // <svg> and contents are ignored by assert.dom.equal() above, so we need
+      // a separate assert.lightDom.equal() for it here.
+      const sizeBarsSVG = queryAndAssert<SVGSVGElement>(
+        element,
+        '.sizeBars > svg'
+      );
+      assert.lightDom.equal(
+        sizeBarsSVG,
+        /* HTML */ `
+          <rect
+            height="8"
+            width="0"
+            x="0"
+            y="0"
+            fill="var(--negative-red-text-color)"
+          ></rect>
+          <rect
+            height="8"
+            width="60"
+            x="1"
+            y="0"
+            fill="var(--positive-green-text-color)"
+          ></rect>
+        `
+      );
     });
 
     test('renders file paths', async () => {
@@ -396,6 +422,7 @@
       element.files = createFiles(250);
       await element.updateComplete;
       await waitEventLoop();
+      assert.equal(200, element.numFilesShown);
 
       assert.equal(
         queryAll<HTMLDivElement>(element, '.file-row').length,
@@ -420,19 +447,9 @@
       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 = [
         {
@@ -723,28 +740,6 @@
       element.basePatchNum = PARENT;
       element.patchNum = 1 as RevisionPatchSetNum;
       assert.equal(
-        element.computeDraftsString({
-          __path: 'unresolved.file',
-          size: 0,
-          size_delta: 0,
-        }),
-        '1 draft'
-      );
-
-      element.basePatchNum = 1 as BasePatchSetNum;
-      element.patchNum = 2 as RevisionPatchSetNum;
-      assert.equal(
-        element.computeDraftsString({
-          __path: 'unresolved.file',
-          size: 0,
-          size_delta: 0,
-        }),
-        '1 draft'
-      );
-
-      element.basePatchNum = PARENT;
-      element.patchNum = 1 as RevisionPatchSetNum;
-      assert.equal(
         element.computeDraftsStringMobile({
           __path: 'unresolved.file',
           size: 0,
@@ -789,28 +784,6 @@
       element.basePatchNum = PARENT;
       element.patchNum = 1 as RevisionPatchSetNum;
       assert.equal(
-        element.computeDraftsString({
-          __path: 'myfile.txt',
-          size: 0,
-          size_delta: 0,
-        }),
-        ''
-      );
-
-      element.basePatchNum = 1 as BasePatchSetNum;
-      element.patchNum = 2 as RevisionPatchSetNum;
-      assert.equal(
-        element.computeDraftsString({
-          __path: 'myfile.txt',
-          size: 0,
-          size_delta: 0,
-        }),
-        ''
-      );
-
-      element.basePatchNum = PARENT;
-      element.patchNum = 1 as RevisionPatchSetNum;
-      assert.equal(
         element.computeDraftsStringMobile({
           __path: 'myfile.txt',
           size: 0,
@@ -855,28 +828,6 @@
       element.basePatchNum = PARENT;
       element.patchNum = 1 as RevisionPatchSetNum;
       assert.equal(
-        element.computeDraftsString({
-          __path: 'file_added_in_rev2.txt',
-          size: 0,
-          size_delta: 0,
-        }),
-        ''
-      );
-
-      element.basePatchNum = 1 as BasePatchSetNum;
-      element.patchNum = 2 as RevisionPatchSetNum;
-      assert.equal(
-        element.computeDraftsString({
-          __path: 'file_added_in_rev2.txt',
-          size: 0,
-          size_delta: 0,
-        }),
-        ''
-      );
-
-      element.basePatchNum = PARENT;
-      element.patchNum = 1 as RevisionPatchSetNum;
-      assert.equal(
         element.computeDraftsStringMobile({
           __path: 'file_added_in_rev2.txt',
           size: 0,
@@ -921,28 +872,6 @@
       element.basePatchNum = PARENT;
       element.patchNum = 1 as RevisionPatchSetNum;
       assert.equal(
-        element.computeDraftsString({
-          __path: '/COMMIT_MSG',
-          size: 0,
-          size_delta: 0,
-        }),
-        '2 drafts'
-      );
-
-      element.basePatchNum = 1 as BasePatchSetNum;
-      element.patchNum = 2 as RevisionPatchSetNum;
-      assert.equal(
-        element.computeDraftsString({
-          __path: '/COMMIT_MSG',
-          size: 0,
-          size_delta: 0,
-        }),
-        '2 drafts'
-      );
-
-      element.basePatchNum = PARENT;
-      element.patchNum = 1 as RevisionPatchSetNum;
-      assert.equal(
         element.computeDraftsStringMobile({
           __path: '/COMMIT_MSG',
           size: 0,
@@ -1444,7 +1373,6 @@
       await waitEventLoop();
 
       const renderSpy = sinon.spy(element, 'renderInOrder');
-      const collapseStub = sinon.stub(element, 'clearCollapsedDiffs');
 
       assert.equal(
         queryAndAssert<GrIcon>(element, 'gr-icon').icon,
@@ -1456,7 +1384,6 @@
       // 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'
@@ -1475,11 +1402,9 @@
       );
       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');
 
@@ -1494,7 +1419,6 @@
       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;
@@ -1502,7 +1426,6 @@
       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 () => {
@@ -1538,19 +1461,6 @@
       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;
@@ -1858,7 +1768,7 @@
         maxDeleted: 0,
         maxAdditionWidth: 0,
         maxDeletionWidth: 0,
-        deletionOffset: 0,
+        additionOffset: 0,
       };
 
       element.files = [];
@@ -1906,7 +1816,7 @@
         maxDeleted: 0,
         maxAdditionWidth: 60,
         maxDeletionWidth: 0,
-        deletionOffset: 60,
+        additionOffset: 60,
       };
 
       // Uses half the space when file is half the largest addition and there
@@ -1934,22 +1844,33 @@
       assert.equal(element.computeBarAdditionWidth(file, stats), 1.5);
     });
 
-    test('_computeBarAdditionX', () => {
+    test('computeBarDeletionX', () => {
       const file = {
         __path: 'foo/bar.baz',
-        lines_inserted: 5,
-        lines_deleted: 0,
+        lines_inserted: 0,
+        lines_deleted: 5,
         size: 0,
         size_delta: 0,
       };
       const stats = {
+        maxInserted: 0,
+        maxDeleted: 10,
+        maxAdditionWidth: 0,
+        maxDeletionWidth: 60,
+        additionOffset: 60,
+      };
+      assert.equal(element.computeBarDeletionX(file, stats), 30);
+    });
+
+    test('computeBarAdditionX', () => {
+      const stats = {
         maxInserted: 10,
         maxDeleted: 0,
         maxAdditionWidth: 60,
         maxDeletionWidth: 0,
-        deletionOffset: 60,
+        additionOffset: 60,
       };
-      assert.equal(element.computeBarAdditionX(file, stats), 30);
+      assert.equal(element.computeBarAdditionX(stats), 60);
     });
 
     test('computeBarDeletionWidth', () => {
@@ -1965,7 +1886,7 @@
         maxDeleted: 10,
         maxAdditionWidth: 30,
         maxDeletionWidth: 30,
-        deletionOffset: 31,
+        additionOffset: 31,
       };
 
       // Uses a quarter the space when file is half the largest deletions and
@@ -1993,7 +1914,7 @@
       assert.equal(element.computeBarDeletionWidth(file, stats), 1.5);
     });
 
-    test('_computeSizeBarsClass', () => {
+    test('computeSizeBarsClass', () => {
       element.showSizeBars = false;
       assert.equal(
         element.computeSizeBarsClass('foo/bar.baz'),
@@ -2240,7 +2161,7 @@
 
     suite('n key presses', () => {
       let nextCommentStub: sinon.SinonStub;
-      let nextChunkStub: sinon.SinonStub;
+      let nextChunkStub: SinonStubbedMember<GrDiffCursor['moveToNextChunk']>;
       let fileRows: NodeListOf<HTMLDivElement>;
 
       setup(() => {
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 33dfe82..8dda1891 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
@@ -10,9 +10,13 @@
 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 {customElement, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 import {fireNoBubble} from '../../../utils/event-util';
+import {resolve} from '../../../models/dependency';
+import {changeModelToken} from '../../../models/change/change-model';
+import {subscribe} from '../../lit/subscription-controller';
+import {formStyles} from '../../../styles/form-styles';
 
 interface DisplayGroup {
   title: string;
@@ -27,21 +31,21 @@
    * @event close
    */
 
-  @property({type: Object})
-  changeNum?: NumericChangeId;
+  @state() changeNum?: NumericChangeId;
 
-  // private but used in test
   @state() includedIn?: IncludedInInfo;
 
   @state() private loaded = false;
 
-  // private but used in test
   @state() filterText = '';
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
   static override get styles() {
     return [
+      formStyles,
       fontStyles,
       sharedStyles,
       css`
@@ -93,6 +97,15 @@
     ];
   }
 
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      changeNum => (this.changeNum = changeNum)
+    );
+  }
+
   override render() {
     return html`
       <header>
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 13662982..bfece63 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
@@ -5,6 +5,7 @@
  */
 import '@polymer/iron-selector/iron-selector';
 import '../../shared/gr-button/gr-button';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css, html, LitElement} from 'lit';
 import {customElement, property, query, state} from 'lit/decorators.js';
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 4abfeff..65d36ec 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -740,9 +740,8 @@
       .deleteChangeCommitMessage(this.changeNum, this.message.id)
       .then(() => {
         this.isDeletingChangeMsg = false;
-        // TODO: Fix the type casting. Might actually be a bug.
         fire(this, 'change-message-deleted', {
-          message: this.message as ChangeMessage,
+          message: this.message!,
         });
       });
   }
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 d5da9c9..3c2b792 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
@@ -21,14 +21,14 @@
   PatchSetNum,
   VotingRangeInfo,
   isRobot,
-  EDIT,
-  PARENT,
+  PatchSetNumber,
 } from '../../../types/common';
 import {GrMessage, MessageAnchorTapDetail} from '../gr-message/gr-message';
 import {getVotingRange} from '../../../utils/label-util';
 import {
   FormattedReviewerUpdateInfo,
   ParsedChangeInfo,
+  isPatchSetNumber,
 } from '../../../types/types';
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {changeModelToken} from '../../../models/change/change-model';
@@ -157,27 +157,17 @@
   message: CombinedMessage,
   allMessages: CombinedMessage[]
 ): PatchSetNum | undefined {
-  if (
-    message._revision_number !== undefined &&
-    message._revision_number !== 0 &&
-    message._revision_number !== PARENT &&
-    message._revision_number !== EDIT
-  ) {
+  if (isPatchSetNumber(message._revision_number)) {
     return message._revision_number;
   }
-  let revision: PatchSetNum = 0 as PatchSetNum;
+  let revision: PatchSetNumber | undefined = undefined;
   for (const m of allMessages) {
     if (m.date > message.date) break;
-    if (
-      m._revision_number !== undefined &&
-      m._revision_number !== 0 &&
-      m._revision_number !== PARENT &&
-      m._revision_number !== EDIT
-    ) {
+    if (isPatchSetNumber(m._revision_number)) {
       revision = m._revision_number;
     }
   }
-  return revision > 0 ? revision : undefined;
+  return revision;
 }
 
 /**
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 90b05f6..af6f2cd 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
@@ -43,9 +43,6 @@
     return [
       sharedStyles,
       css`
-        a {
-          display: block;
-        }
         :host,
         .changeContainer,
         a {
@@ -53,9 +50,8 @@
           overflow: hidden;
           text-overflow: ellipsis;
           white-space: nowrap;
-        }
-        .changeContainer {
-          display: flex;
+          width: 100%;
+          display: inline-flex;
         }
         .strikethrough {
           color: var(--deemphasized-text-color);
@@ -65,6 +61,7 @@
           color: var(--deemphasized-text-color);
           font-weight: var(--font-weight-bold);
           margin-left: var(--spacing-xs);
+          margin-right: var(--spacing-m);
         }
         .notCurrent {
           color: var(--warning-foreground);
@@ -106,7 +103,7 @@
           href=${ifDefined(this.href)}
           aria-label=${ifDefined(this.label)}
           class=${linkClass}
-          ><slot></slot
+          ><slot name="name"></slot
         ></a>
         ${this.showSubmittableCheck
           ? html`<span
@@ -123,6 +120,7 @@
               (${this.computeChangeStatus(change)})
             </span>`
           : ''}
+        <slot name="extra"></slot>
       </div>
     `;
   }
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 2ba0cf8..984391d 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
@@ -25,7 +25,7 @@
 import {truncatePath} from '../../../utils/path-list-util';
 import {pluralize} from '../../../utils/string-util';
 import {getChangeNumber, getRevisionKey} from '../../../utils/change-util';
-import {DEFALT_NUM_CHANGES_WHEN_COLLAPSED} from './gr-related-collapse';
+import {DEFAULT_NUM_CHANGES_WHEN_COLLAPSED} from './gr-related-collapse';
 import {createChangeUrl} from '../../../models/views/change';
 import {subscribe} from '../../lit/subscription-controller';
 import {resolve} from '../../../models/dependency';
@@ -130,10 +130,30 @@
         section {
           margin-bottom: var(--spacing-l);
         }
+        gr-related-collapse .relatedChangeLine:first-child {
+          border-top-left-radius: var(--border-radius);
+          border-top-right-radius: var(--border-radius);
+        }
+        gr-related-collapse .relatedChangeLine:last-child {
+          border-bottom-left-radius: var(--border-radius);
+          border-bottom-right-radius: var(--border-radius);
+          border-bottom: 1px solid var(--border-color);
+        }
         .relatedChangeLine {
+          background-color: var(--background-color-primary);
           display: flex;
+          width: 100%;
           visibility: visible;
           height: auto;
+          padding-bottom: 2px;
+          padding-top: 2px;
+          border-left: 1px solid var(--border-color);
+          border-right: 1px solid var(--border-color);
+          border-top: 1px solid var(--border-color);
+          padding-right: var(--spacing-m);
+        }
+        .relatedChangeLine.selected {
+          background-color: var(--selection-background-color);
         }
         .marker.arrow {
           visibility: hidden;
@@ -168,6 +188,17 @@
         gr-related-collapse[collapsed] .relatedChangeLine.show-when-collapsed {
           visibility: visible;
           height: auto;
+          padding-bottom: 2px;
+          padding-top: 2px;
+          border-top: 1px solid var(--border-color);
+        }
+        gr-related-collapse[collapsed]
+          .relatedChangeLine.show-when-collapsed:last-child {
+          border-bottom: 1px solid var(--border-color);
+        }
+        gr-related-collapse[collapsed]
+          .relatedChangeLine.show-when-collapsed.bottom {
+          border-bottom: 1px solid var(--border-color);
         }
         /* keep width, so width of section and position of show all button
          * are set according to width of all (even hidden) elements
@@ -175,6 +206,10 @@
         gr-related-collapse[collapsed] .relatedChangeLine {
           visibility: hidden;
           height: 0px;
+          padding-top: 0px;
+          padding-bottom: 0px;
+          border-bottom: none;
+          border-top: none;
         }
       `,
     ];
@@ -252,6 +287,10 @@
             html`<div
               class=${classMap({
                 ['relatedChangeLine']: true,
+                ['selected']:
+                  relatedChangesMarkersPredicate(index).showCurrentChangeArrow,
+                ['bottom']:
+                  relatedChangesMarkersPredicate(index).showBottomArrow,
                 ['show-when-collapsed']:
                   relatedChangesMarkersPredicate(index).showWhenCollapsed,
               })}
@@ -271,7 +310,9 @@
                   : ''}
                 show-change-status
                 show-submittable-check
-                >${change.commit.subject}</gr-related-change
+                ><span slot="name"
+                  >${change.commit.subject}</span
+                ></gr-related-change
               >
             </div>`
         )}
@@ -311,6 +352,11 @@
             html`<div
               class=${classMap({
                 ['relatedChangeLine']: true,
+                ['selected']:
+                  submittedTogetherMarkersPredicate(index)
+                    .showCurrentChangeArrow,
+                ['bottom']:
+                  submittedTogetherMarkersPredicate(index).showBottomArrow,
                 ['show-when-collapsed']:
                   submittedTogetherMarkersPredicate(index).showWhenCollapsed,
               })}
@@ -335,10 +381,12 @@
         .change=${change}
         .href=${createChangeUrl({change, usp: 'submitted-together'})}
         show-submittable-check
-        >${change.subject}</gr-related-change
+        ><span slot="name">${change.subject}</span
+        ><span slot="extra"
+          ><span class="repo" .title=${change.project}>${truncatedRepo}</span
+          ><span class="branch">&nbsp;|&nbsp;${change.branch}&nbsp;</span></span
+        ></gr-related-change
       >
-      <span class="repo" .title=${change.project}>${truncatedRepo}</span
-      ><span class="branch">&nbsp;|&nbsp;${change.branch}&nbsp;</span>
     `;
   }
 
@@ -367,6 +415,9 @@
             html`<div
               class=${classMap({
                 ['relatedChangeLine']: true,
+                ['selected']:
+                  sameTopicMarkersPredicate(index).showCurrentChangeArrow,
+                ['bottom']: sameTopicMarkersPredicate(index).showBottomArrow,
                 ['show-when-collapsed']:
                   sameTopicMarkersPredicate(index).showWhenCollapsed,
               })}
@@ -404,6 +455,10 @@
             html`<div
               class=${classMap({
                 ['relatedChangeLine']: true,
+                ['selected']:
+                  mergeConflictsMarkersPredicate(index).showCurrentChangeArrow,
+                ['bottom']:
+                  mergeConflictsMarkersPredicate(index).showBottomArrow,
                 ['show-when-collapsed']:
                   mergeConflictsMarkersPredicate(index).showWhenCollapsed,
               })}
@@ -413,7 +468,7 @@
               )}<gr-related-change
                 .change=${change}
                 .href=${createChangeUrl({change, usp: 'merge-conflict'})}
-                >${change.subject}</gr-related-change
+                ><span slot="name">${change.subject}</span></gr-related-change
               >
             </div>`
         )}
@@ -455,7 +510,9 @@
                 .change=${change}
                 .href=${createChangeUrl({change, usp: 'cherry-pick'})}
                 show-change-status
-                >${change.branch}: ${change.subject}</gr-related-change
+                ><span slot="name"
+                  >${change.branch}: ${change.subject}</span
+                ></gr-related-change
               >
             </div>`
         )}
@@ -475,7 +532,7 @@
     cherryPicksLen: number
   ) {
     const calcDefaultSize = (length: number) =>
-      Math.min(length, DEFALT_NUM_CHANGES_WHEN_COLLAPSED);
+      Math.min(length, DEFAULT_NUM_CHANGES_WHEN_COLLAPSED);
 
     const sectionSizes = [
       {
@@ -527,14 +584,14 @@
     return (section: Section) => {
       const sizeObj = sectionSizes.find(sizeObj => sizeObj.section === section);
       if (sizeObj) return sizeObj.size;
-      return DEFALT_NUM_CHANGES_WHEN_COLLAPSED;
+      return DEFAULT_NUM_CHANGES_WHEN_COLLAPSED;
     };
   }
 
   markersPredicateFactory(
     length: number,
     highlightIndex: number,
-    numChangesShownWhenCollapsed = DEFALT_NUM_CHANGES_WHEN_COLLAPSED
+    numChangesShownWhenCollapsed = DEFAULT_NUM_CHANGES_WHEN_COLLAPSED
   ): (index: number) => ChangeMarkersInList {
     const showWhenCollapsedPredicate = (index: number) => {
       if (highlightIndex === -1) return index < numChangesShownWhenCollapsed;
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 24a8217..2e33333 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
@@ -207,14 +207,14 @@
                     show-change-status=""
                     show-submittable-check=""
                   >
-                    Test commit subject
+                    <span slot="name">Test commit subject</span>
                   </gr-related-change>
                 </div>
               </gr-related-collapse>
             </section>
             <section id="submittedTogether">
               <gr-related-collapse title="Submitted together">
-                <div class="relatedChangeLine show-when-collapsed">
+                <div class="relatedChangeLine selected show-when-collapsed">
                   <span
                     aria-label="Arrow marking current change"
                     class="arrowToCurrentChange marker"
@@ -223,10 +223,14 @@
                     âž”
                   </span>
                   <gr-related-change show-submittable-check="">
-                    Test subject
+                    <span slot="name">Test subject</span>
+                    <span slot="extra">
+                      <span class="repo" title="test-project"
+                        >test-project</span
+                      >
+                      <span class="branch">&nbsp;|&nbsp;test-branch&nbsp;</span>
+                    </span>
                   </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>
@@ -236,7 +240,7 @@
                 <div class="relatedChangeLine show-when-collapsed">
                   <span class="marker space"> </span>
                   <gr-related-change show-change-status="">
-                    test-branch: Test subject
+                    <span slot="name"> test-branch: Test subject </span>
                   </gr-related-change>
                 </div>
               </gr-related-collapse>
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
index 61136d7..30d2282 100644
--- 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
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {LitElement, css, html, nothing, TemplateResult} from 'lit';
+import '../../shared/gr-button/gr-button';
 import '../../shared/gr-icon/gr-icon';
 import {customElement, property} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -12,7 +13,7 @@
 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;
+export const DEFAULT_NUM_CHANGES_WHEN_COLLAPSED = 3;
 
 @customElement('gr-related-collapse')
 export class GrRelatedCollapse extends LitElement {
@@ -29,7 +30,7 @@
   length = 0;
 
   @property({type: Number})
-  numChangesWhenCollapsed = DEFALT_NUM_CHANGES_WHEN_COLLAPSED;
+  numChangesWhenCollapsed = DEFAULT_NUM_CHANGES_WHEN_COLLAPSED;
 
   private readonly reporting = getAppContext().reportingService;
 
@@ -42,7 +43,6 @@
           color: var(--deemphasized-text-color);
           display: flex;
           align-self: flex-end;
-          margin-left: 20px;
         }
         gr-button {
           display: flex;
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 e582607..a0ad2f0 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
@@ -58,6 +58,7 @@
   Suggestion,
   UserId,
   isDraft,
+  ChangeViewChangeInfo,
 } from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrLabelScores} from '../gr-label-scores/gr-label-scores';
@@ -99,11 +100,7 @@
 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 {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';
@@ -130,6 +127,10 @@
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {ironAnnouncerRequestAvailability} from '../../polymer-util';
+import {GrReviewerUpdatesParser} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {formStyles} from '../../../styles/form-styles';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {getDocUrl} from '../../../utils/url-util';
 
 export enum FocusTarget {
   ANY = 'any',
@@ -189,9 +190,6 @@
   @property({type: Object})
   permittedLabels?: LabelNameToValuesMap;
 
-  @property({type: Object})
-  projectConfig?: ConfigInfo;
-
   @query('#patchsetLevelComment') patchsetLevelGrComment?: GrComment;
 
   @query('#reviewers') reviewersList?: GrAccountList;
@@ -211,6 +209,8 @@
 
   @state() serverConfig?: ServerInfo;
 
+  @state() private docsBaseUrl = '';
+
   @state()
   patchsetLevelDraftMessage = '';
 
@@ -328,6 +328,12 @@
   newAttentionSet: Set<UserId> = new Set();
 
   @state()
+  manuallyAddedAttentionSet: Set<UserId> = new Set();
+
+  @state()
+  manuallyDeletedAttentionSet: Set<UserId> = new Set();
+
+  @state()
   patchsetLevelDraftIsResolved = true;
 
   @state()
@@ -347,229 +353,236 @@
 
   private readonly getUserModel = resolve(this, userModelToken);
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
   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.
+  static override get styles() {
+    return [
+      formStyles,
+      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;
+        }
+        .edit-attention-button {
+          vertical-align: top;
+          --gr-button-padding: 0px 4px;
+        }
+        .edit-attention-button gr-icon {
+          color: inherit;
+        }
+        .attentionSummary .edit-attention-button gr-icon {
+          /* 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
+          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(--line-item-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);
-      }
-    `,
-  ];
+          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(--line-item-highlight-color);
+        }
+        .attentionTip div gr-icon {
+          margin-right: var(--spacing-s);
+        }
+        .patchsetLevelContainer {
+          width: calc(min(80ch, 100%));
+          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();
@@ -594,6 +607,13 @@
     );
     subscribe(
       this,
+      () => this.getUserModel().account$,
+      account => {
+        this.account = account;
+      }
+    );
+    subscribe(
+      this,
       () => this.getConfigModel().serverConfig$,
       config => {
         this.serverConfig = config;
@@ -601,6 +621,11 @@
     );
     subscribe(
       this,
+      () => this.getConfigModel().docsBaseUrl$,
+      docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
+    );
+    subscribe(
+      this,
       () => this.getChangeModel().change$,
       x => (this.change = x)
     );
@@ -656,10 +681,6 @@
       this
     );
 
-    this.restApiService.getAccount().then(account => {
-      if (account) this.account = account;
-    });
-
     this.addEventListener(
       'comment-editing-changed',
       (e: CustomEvent<CommentEditingChangedDetail>) => {
@@ -992,33 +1013,13 @@
                 )}
               `
             )}
-            <gr-tooltip-content
-              has-tooltip
-              title=${this.computeAttentionButtonTitle()}
-            >
-              <gr-button
-                class="edit-attention-button"
-                @click=${this.handleAttentionModify}
-                ?disabled=${this.isSendDisabled()}
-                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>
+            ${this.renderModifyAttentionSetButton()}
             <a
-              href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+              href=${getDocUrl(this.docsBaseUrl, 'user-attention-set.html')}
               target="_blank"
+              rel="noopener noreferrer"
             >
               <gr-icon icon="help" title="read documentation"></gr-icon>
             </a>
@@ -1028,6 +1029,29 @@
     `;
   }
 
+  private renderModifyAttentionSetButton() {
+    return html` <gr-button
+      class="edit-attention-button"
+      @click=${this.toggleAttentionModify}
+      link
+      position-below
+      data-label="Edit"
+      data-action-type="change"
+      data-action-key="edit"
+      role="button"
+      tabindex="0"
+    >
+      <div>
+        <gr-icon
+          icon=${this.attentionExpanded ? 'expand_circle_up' : 'edit'}
+          filled
+          small
+        ></gr-icon>
+        <span>${this.attentionExpanded ? 'Collapse' : 'Modify'}</span>
+      </div>
+    </gr-button>`;
+  }
+
   private renderAttentionDetailsSection() {
     if (!this.attentionExpanded) return;
     return html`
@@ -1036,11 +1060,14 @@
           <div>
             <span>Modify attention to</span>
           </div>
+
           <div></div>
           <div>
+            ${this.renderModifyAttentionSetButton()}
             <a
-              href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+              href=${getDocUrl(this.docsBaseUrl, 'user-attention-set.html')}
               target="_blank"
+              rel="noopener noreferrer"
             >
               <gr-icon icon="help" title="read documentation"></gr-icon>
             </a>
@@ -1128,7 +1155,7 @@
           `
         )}
         ${when(
-          this.computeShowAttentionTip(),
+          this.computeShowAttentionTip(3),
           () => html`
             <div class="attentionTip">
               <gr-icon icon="lightbulb"></gr-icon>
@@ -1336,7 +1363,8 @@
 
   // visible for testing
   async send(includeComments: boolean, startReview: boolean) {
-    // The change model will end this timing when the change was reloaded.
+    // ChangeModel will be updated once the reply returns at which point the
+    // timer will be ended.
     this.reporting.time(Timing.SEND_REPLY);
     const labels = this.getLabelScores().getLabelValues();
 
@@ -1404,23 +1432,29 @@
       reviewInput.remove_from_attention_set
     );
 
-    await this.patchsetLevelGrComment?.save();
+    if (this.patchsetLevelGrComment) {
+      this.patchsetLevelGrComment.disableAutoSaving = true;
+      await this.restApiService.awaitPendingDiffDrafts();
+      const comment = this.patchsetLevelGrComment.convertToCommentInput();
+      if (comment && comment.path && comment.message) {
+        reviewInput.comments ??= {};
+        reviewInput.comments[comment.path] ??= [];
+        reviewInput.comments[comment.path].push(comment);
+      }
+    }
 
     assertIsDefined(this.change, 'change');
     reviewInput.reviewers = this.computeReviewers();
 
     const errFn = (r?: Response | null) => this.handle400Error(r);
+    this.getNavigation().blockNavigation('sending review');
     return this.saveReview(reviewInput, errFn)
-      .then(response => {
-        if (!response) {
-          // Null or undefined response indicates that an error handler
-          // took responsibility, so just return.
-          return;
-        }
-        if (!response.ok) {
-          fireServerError(response);
-          return;
-        }
+      .then(result => {
+        this.getChangeModel().updateStateChange(
+          GrReviewerUpdatesParser.parse(
+            result?.change_info as ChangeViewChangeInfo
+          )
+        );
 
         this.patchsetLevelDraftMessage = '';
         this.includeComments = true;
@@ -1428,13 +1462,15 @@
         fireIronAnnounce(this, 'Reply sent');
         return;
       })
-      .then(result => {
+      .then(result => result)
+      .finally(() => {
+        this.getNavigation().releaseNavigation('sending review');
         this.disabled = false;
-        return result;
-      })
-      .catch(err => {
-        this.disabled = false;
-        throw err;
+        if (this.patchsetLevelGrComment) {
+          this.patchsetLevelGrComment.disableAutoSaving = false;
+        }
+        // By this point in time the change has loaded, we're only waiting for the comments.
+        this.reporting.timeEnd(Timing.SEND_REPLY);
       });
   }
 
@@ -1522,8 +1558,8 @@
     this.reviewers = getAccounts(ReviewerState.REVIEWER);
   }
 
-  handleAttentionModify() {
-    this.attentionExpanded = true;
+  toggleAttentionModify() {
+    this.attentionExpanded = !this.attentionExpanded;
   }
 
   onAttentionExpandedChange() {
@@ -1532,13 +1568,6 @@
     fire(this, 'iron-resize', {});
   }
 
-  computeAttentionButtonTitle() {
-    return this.isSendDisabled()
-      ? '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 targetAccount = (e.target as GrAccountChip)?.account;
     if (!targetAccount) return;
@@ -1550,11 +1579,15 @@
 
     if (this.newAttentionSet.has(id)) {
       this.newAttentionSet.delete(id);
+      this.manuallyAddedAttentionSet.delete(id);
+      this.manuallyDeletedAttentionSet.add(id);
       this.reporting.reportInteraction(Interaction.ATTENTION_SET_CHIP, {
         action: `REMOVE${self}${role}`,
       });
     } else {
       this.newAttentionSet.add(id);
+      this.manuallyAddedAttentionSet.add(id);
+      this.manuallyDeletedAttentionSet.delete(id);
       this.reporting.reportInteraction(Interaction.ATTENTION_SET_CHIP, {
         action: `ADD${self}${role}`,
       });
@@ -1644,18 +1677,24 @@
       .map(a => getUserId(a))
       .filter(id => !!id);
     this.newAttentionSet = new Set([
-      ...[...newAttention].filter(id => allAccountIds.includes(id)),
+      ...this.manuallyAddedAttentionSet,
+      ...[...newAttention].filter(
+        id =>
+          allAccountIds.includes(id) &&
+          !this.manuallyDeletedAttentionSet.has(id)
+      ),
     ]);
-
-    this.attentionExpanded = this.computeShowAttentionTip();
+    // Possibly expand if need be, never collapse as this is jarring to the user.
+    this.attentionExpanded =
+      this.attentionExpanded || this.computeShowAttentionTip(1);
   }
 
-  computeShowAttentionTip() {
+  computeShowAttentionTip(minimum: number) {
     if (!this.currentAttentionSet || !this.newAttentionSet) return false;
     const addedIds = [...this.newAttentionSet].filter(
       id => !this.currentAttentionSet.has(id)
     );
-    return this.isOwner && addedIds.length > 2;
+    return this.isOwner && addedIds.length >= minimum;
   }
 
   computeCommentAccounts(threads: CommentThread[]) {
@@ -1832,7 +1871,8 @@
       this.change._number,
       this.latestPatchNum,
       review,
-      errFn
+      errFn,
+      true
     );
   }
 
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 c4a978c..500aa63 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
@@ -13,6 +13,7 @@
   query,
   queryAll,
   queryAndAssert,
+  stubReporting,
   stubRestApi,
   waitUntilVisible,
 } from '../../../test/test-utils';
@@ -21,7 +22,6 @@
   DraftsAction,
   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,
@@ -70,6 +70,7 @@
 } from '../../../models/comments/comments-model';
 import {isOwner} from '../../../utils/change-util';
 import {createNewPatchsetLevel} from '../../../utils/comment-util';
+import {Timing} from '../../../constants/reporting';
 
 function cloneableResponse(status: number, text: string) {
   return {
@@ -170,14 +171,7 @@
         new Promise((resolve, reject) => {
           try {
             const result = jsonResponseProducer(review) || {};
-            const resultStr = JSON_PREFIX + JSON.stringify(result);
-            resolve({
-              ...new Response(),
-              ok: true,
-              text() {
-                return Promise.resolve(resultStr);
-              },
-            });
+            resolve(result);
           } catch (err) {
             reject(err);
           }
@@ -263,33 +257,28 @@
                 <div class="attentionSummary">
                   <div>
                     <span> No changes to the attention set. </span>
-                    <gr-tooltip-content
-                      has-tooltip=""
-                      title="Modify the attention set by adding a comment or use the account hovercard in the change page."
-                    >
-                      <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>
+                    <gr-button
+                      aria-disabled="false"
+                      class="edit-attention-button"
+                      data-action-key="edit"
+                      data-action-type="change"
+                      data-label="Edit"
+                      link=""
+                      position-below=""
+                      role="button"
+                      tabindex="0"
+                    >
+                      <div>
+                        <gr-icon icon="edit" filled small></gr-icon>
+                        <span>Modify</span>
+                      </div>
+                    </gr-button>
                     <a
-                      href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+                      href="/Documentation/user-attention-set.html"
                       target="_blank"
+                      rel="noopener noreferrer"
                     >
                       <gr-icon icon="help" title="read documentation"></gr-icon>
                     </a>
@@ -449,6 +438,28 @@
     );
   });
 
+  test('save review fires sendReply metric', async () => {
+    const timeEndStub = stubReporting('timeEnd');
+
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    await element.updateComplete;
+    element.patchsetLevelDraftMessage = 'I wholeheartedly disapprove';
+    element.draftCommentThreads = [createCommentThread([createComment()])];
+
+    element.includeComments = true;
+
+    // This is needed on non-Blink engines most likely due to the ways in
+    // which the dom-repeat elements are stamped.
+    await element.updateComplete;
+    queryAndAssert<GrButton>(element, '.send').click();
+
+    await interceptSaveReview();
+    await element.updateComplete;
+
+    await waitUntil(() => timeEndStub.calledWith(Timing.SEND_REPLY));
+  });
+
   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.
@@ -1521,62 +1532,6 @@
     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,
-            '....{"reviewers":{"id1":{"error":"human readable"}}}'
-          ) as Response
-        );
-        return Promise.resolve(new Response());
-      }
-    );
-
-    const promise = mockPromise();
-    const listener = (event: Event) => {
-      if (event.target !== document) return;
-      (event as CustomEvent).detail.response.text().then((body: string) => {
-        if (body === 'human readable') {
-          promise.resolve();
-        }
-      });
-    };
-    addListenerForTest(document, 'server-error', listener);
-
-    await element.updateComplete;
-    element.send(false, false);
-    await promise;
-  });
-
-  test('non-json 400 is treated as a normal server-error', async () => {
-    stubRestApi('saveChangeReview').callsFake(
-      (_changeNum, _patchNum, _review, errFn) => {
-        // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
-        errFn!(cloneableResponse(400, 'Comment validation error!') as Response);
-        return Promise.resolve(new Response());
-      }
-    );
-    const promise = mockPromise();
-    const listener = (event: Event) => {
-      if (event.target !== document) return;
-      (event as CustomEvent).detail.response.text().then((body: string) => {
-        if (body === 'Comment validation error!') {
-          promise.resolve();
-        }
-      });
-    };
-    addListenerForTest(document, 'server-error', listener);
-
-    // Async tick is needed because iron-selector content is distributed and
-    // distributed content requires an observer to be set up.
-    await element.updateComplete;
-    element.send(false, false);
-    await promise;
-  });
-
   test('filterReviewerSuggestion', () => {
     const owner = makeAccount();
     const reviewer1 = makeAccount();
@@ -1775,15 +1730,6 @@
 
     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(
@@ -1795,19 +1741,17 @@
     element._ccs = [...element.ccs, makeAccount()];
     await element.updateComplete;
 
-    // The 'attention modified' section collapses and resets when reviewers or
-    // ccs change.
-    assert.isFalse(element.attentionExpanded);
-
-    queryAndAssert<GrButton>(element, '.edit-attention-button').click();
-    await element.updateComplete;
-
     assert.isTrue(element.attentionExpanded);
     accountLabels = Array.from(
       queryAll(element, '.attention-detail gr-account-label')
     );
     assert.equal(accountLabels.length, 7);
 
+    // Verify that toggling the attention-set-button collapses.
+    queryAndAssert<GrButton>(element, '.edit-attention-button').click();
+    await element.updateComplete;
+    assert.isFalse(element.attentionExpanded);
+
     element.reviewers.pop();
     element.reviewers.pop();
     element._ccs.pop();
@@ -2489,6 +2433,7 @@
       await waitUntil(
         () => element.patchsetLevelDraftMessage === 'hello world'
       );
+      await element.updateComplete;
 
       const saveReviewPromise = interceptSaveReview();
 
@@ -2498,7 +2443,7 @@
 
       const review = await saveReviewPromise;
 
-      assert.deepEqual(autoSaveStub.callCount, 1);
+      assert.deepEqual(autoSaveStub.callCount, 0);
 
       assert.deepEqual(review, {
         drafts: DraftsAction.PUBLISH_ALL_REVISIONS,
@@ -2513,6 +2458,65 @@
             user: 999 as UserId,
           },
         ],
+        comments: {
+          '/PATCHSET_LEVEL': [
+            {
+              message: 'hello world',
+              path: '/PATCHSET_LEVEL',
+              unresolved: false,
+            },
+          ],
+        },
+        remove_from_attention_set: [],
+        ignore_automatic_attention_set_rules: true,
+      });
+    });
+
+    test('sending waits for inflight autosave', async () => {
+      const patchsetLevelComment = queryAndAssert<GrComment>(
+        element,
+        '#patchsetLevelComment'
+      );
+
+      const waitForPendingDiffDrafts = stubRestApi(
+        'awaitPendingDiffDrafts'
+      ).returns(Promise.resolve());
+
+      patchsetLevelComment.messageText = 'hello world';
+      await waitUntil(
+        () => element.patchsetLevelDraftMessage === 'hello world'
+      );
+      await element.updateComplete;
+
+      const saveReviewPromise = interceptSaveReview();
+
+      queryAndAssert<GrButton>(element, '.send').click();
+
+      const review = await saveReviewPromise;
+      assert.deepEqual(waitForPendingDiffDrafts.callCount, 1);
+
+      assert.deepEqual(review, {
+        drafts: DraftsAction.PUBLISH_ALL_REVISIONS,
+        labels: {
+          'Code-Review': 0,
+          Verified: 0,
+        },
+        reviewers: [],
+        add_to_attention_set: [
+          {
+            reason: '<GERRIT_ACCOUNT_1> replied on the change',
+            user: 999 as UserId,
+          },
+        ],
+        comments: {
+          '/PATCHSET_LEVEL': [
+            {
+              message: 'hello world',
+              path: '/PATCHSET_LEVEL',
+              unresolved: false,
+            },
+          ],
+        },
         remove_from_attention_set: [],
         ignore_automatic_attention_set_rules: true,
       });
@@ -2559,6 +2563,59 @@
     });
   });
 
+  test('manually added users are not lost when view updates.', async () => {
+    assert.sameMembers([...element.newAttentionSet], []);
+
+    element.reviewers = [
+      createAccountWithId(1),
+      createAccountWithId(2),
+      createAccountWithId(3),
+    ];
+    element.patchsetLevelDraftMessage = 'abc';
+
+    await element.updateComplete;
+    assert.sameMembers(
+      [...element.newAttentionSet],
+      [2 as AccountId, 3 as AccountId, 999 as AccountId]
+    );
+
+    const modifyButton = queryAndAssert<GrButton>(
+      element,
+      '.edit-attention-button'
+    );
+
+    modifyButton.click();
+    assert.isTrue(element.attentionExpanded);
+    await element.updateComplete;
+
+    const accountsChips = Array.from(
+      queryAll<GrAccountLabel>(element, '.attention-detail gr-account-label')
+    );
+    assert.equal(accountsChips.length, 4);
+    for (let i = 0; i < 4; ++i) {
+      if (accountsChips[i].account?._account_id === 1) {
+        accountsChips[i].click();
+        break;
+      }
+    }
+
+    await element.updateComplete;
+
+    assert.sameMembers(
+      [...element.newAttentionSet],
+      [1 as AccountId, 2 as AccountId, 3 as AccountId, 999 as AccountId]
+    );
+
+    // Doesn't get reset when message changes.
+    element.patchsetLevelDraftMessage = 'def';
+    await element.updateComplete;
+
+    assert.sameMembers(
+      [...element.newAttentionSet],
+      [1 as AccountId, 2 as AccountId, 3 as AccountId, 999 as AccountId]
+    );
+  });
+
   suite('mention users', () => {
     setup(async () => {
       element.account = createAccountWithId(1);
@@ -2695,6 +2752,13 @@
       await element.updateComplete;
 
       assert.sameMembers([...element.newAttentionSet], [999 as AccountId]);
+
+      // Random update
+      element.patchsetLevelDraftMessage = 'abc';
+      await element.updateComplete;
+
+      assert.sameMembers([...element.newAttentionSet], [999 as AccountId]);
+      element.patchsetLevelDraftMessage = 'abc';
     });
 
     test('mention user who is already CCed', async () => {
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 db19329..3bc2770 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
@@ -25,6 +25,8 @@
 import {nothing} from 'lit';
 import {fire} from '../../../utils/event-util';
 import {ShowReplyDialogEvent} from '../../../types/events';
+import {repeat} from 'lit/directives/repeat.js';
+import {accountKey} from '../../../utils/account-util';
 
 @customElement('gr-reviewer-list')
 export class GrReviewerList extends LitElement {
@@ -102,8 +104,10 @@
     return html`
       <div class="container">
         <div>
-          ${this.displayedReviewers.map(reviewer =>
-            this.renderAccountChip(reviewer)
+          ${repeat(
+            this.displayedReviewers,
+            reviewer => accountKey(reviewer),
+            reviewer => this.renderAccountChip(reviewer)
           )}
           <div class="controlsContainer" ?hidden=${!this.mutable}>
             <gr-button
diff --git a/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents.ts b/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents.ts
new file mode 100644
index 0000000..9cf5423
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents.ts
@@ -0,0 +1,415 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {customElement, state} from 'lit/decorators.js';
+import {css, html, HTMLTemplateResult, LitElement} from 'lit';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
+import {changeModelToken} from '../../../models/change/change-model';
+import {
+  CommitId,
+  EDIT,
+  NumericChangeId,
+  ParentInfo,
+  PatchSetNumber,
+  RepoName,
+  RevisionInfo,
+} from '../../../api/rest-api';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {branchName} from '../../../utils/patch-set-util';
+import {when} from 'lit/directives/when.js';
+import {createChangeUrl} from '../../../models/views/change';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {configModelToken} from '../../../models/config/config-model';
+import {getDocUrl} from '../../../utils/url-util';
+
+@customElement('gr-revision-parents')
+export class GrRevisionParents extends LitElement {
+  @state() repo?: RepoName;
+
+  @state() revision?: RevisionInfo;
+
+  @state() baseRevision?: RevisionInfo;
+
+  @state() showDetails = false;
+
+  @state() docsBaseUrl = '';
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getChangeModel().revision$,
+      x => {
+        if (x?._number === EDIT) x = undefined;
+        this.revision = x as RevisionInfo | undefined;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().repo$,
+      x => (this.repo = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().baseRevision$,
+      x => (this.baseRevision = x)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().docsBaseUrl$,
+      x => (this.docsBaseUrl = x)
+    );
+  }
+
+  static override get styles() {
+    return [
+      fontStyles,
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        div.container {
+          padding: var(--spacing-m) var(--spacing-l);
+          border-top: 1px solid var(--border-color);
+        }
+        .sections {
+          display: flex;
+        }
+        .section {
+          margin-top: 0;
+          padding-right: var(--spacing-xxl);
+        }
+        .section h4 {
+          margin: 0;
+        }
+        .title {
+          font-weight: var(--font-weight-bold);
+        }
+        .messageContainer {
+          display: flex;
+          padding: var(--spacing-m) var(--spacing-l);
+          border-top: 1px solid var(--border-color);
+        }
+        .messageContainer.info {
+          background-color: var(--info-background);
+        }
+        .messageContainer.warning {
+          background-color: var(--warning-background);
+        }
+        .messageContainer gr-icon {
+          margin-right: var(--spacing-m);
+        }
+        .messageContainer.info gr-icon {
+          color: var(--info-foreground);
+        }
+        .messageContainer.warning gr-icon {
+          color: var(--warning-foreground);
+        }
+        .messageContainer .text {
+          max-width: 600px;
+        }
+        .messageContainer .text p {
+          margin: 0;
+        }
+        .messageContainer .text gr-button {
+          margin-left: -4px;
+        }
+        gr-commit-info {
+          display: inline-block;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`${this.renderMessage()}${this.renderDetails()}`;
+  }
+
+  private renderMessage() {
+    if (!this.baseRevision || !this.revision) return;
+    // For merges we are only interested in the target branch parent, which is [0].
+    // And for non-merges there is no more than 1 parent, so [0] is the only choice.
+    const parentLeft = this.baseRevision?.parents_data?.[0];
+    const parentRight = this.revision?.parents_data?.[0];
+    // Note that is you diff a patchset against its base, then baseRevision will be
+    // `undefined`. Thus after this line we know that we are dealing with diffs
+    // of the type "patchset x vs patchset y".
+    if (!parentLeft || !parentRight) return;
+
+    const psLeft = this.baseRevision?._number;
+    const psRight = this.revision?._number;
+    const parentCommitLeft = parentLeft.commit_id;
+    const parentCommitRight = parentRight.commit_id;
+    const branchLeft = branchName(parentLeft.branch_name);
+    const branchRight = branchName(parentRight.branch_name);
+    const isMergedLeft = parentLeft.is_merged_in_target_branch;
+    const isMergedRight = parentRight.is_merged_in_target_branch;
+    const changeNumLeft = parentLeft.change_number;
+    const changeNumRight = parentRight.change_number;
+    const changePsLeft = parentLeft.patch_set_number;
+    const changePsRight = parentRight.patch_set_number;
+
+    if (parentCommitLeft === parentCommitRight) return;
+
+    // Subsequently: different commit
+
+    if (branchLeft !== branchRight) {
+      return html`
+        ${this.renderWarning(
+          'warning',
+          html`
+            Patchset ${psLeft} and ${psRight} are targeting different branches.
+          `
+        )}
+      `;
+    }
+
+    // Subsequently: different commit, same target branch
+
+    // Such a situation is really rare and weird. You have to do something like committing to one
+    // branch and then uploading to another. This warning should actually also be shown, if
+    // you are not comparing PS X and PS Y, because it is generally a weird patchset state.
+    const isWeirdLeft = !isMergedLeft && !changeNumLeft;
+    const isWeirdRight = !isMergedRight && !changeNumRight;
+    if (isWeirdLeft || isWeirdRight) {
+      const weirdPs =
+        isWeirdLeft && isWeirdRight
+          ? `${psLeft} and ${psRight} are`
+          : isWeirdLeft
+          ? `${psLeft} is`
+          : `${psRight} is`;
+      return html`
+        ${this.renderWarning(
+          'warning',
+          html`
+            Patchset ${weirdPs} based on a commit that neither exists in its
+            target branch, nor is it a commit of another active change.
+          `
+        )}
+      `;
+    }
+
+    if (
+      changeNumLeft &&
+      changeNumRight &&
+      changeNumLeft === changeNumRight &&
+      // This check is probably redundant, because "same change and ps" should mean "same commit".
+      psLeft !== psRight
+    ) {
+      return html`
+        ${this.renderWarning(
+          'info',
+          html`
+            The change was rebased from patchset
+            ${this.renderPatchsetLink(changeNumLeft, changePsLeft)} onto
+            patchset ${this.renderPatchsetLink(changeNumLeft, changePsRight)} of
+            change ${this.renderChangeLink(changeNumLeft)}
+            ${when(isMergedRight, () => html` (MERGED)`)}.
+          `
+        )}
+      `;
+    }
+
+    // No additional info? Then "different commit" and "same branch" means "standard rebase".
+    if (isMergedLeft && isMergedRight) {
+      return html`
+        ${this.renderWarning(
+          'info',
+          html`
+            The change was rebased from
+            ${this.renderCommitLink(parentCommitLeft, false)} onto
+            ${this.renderCommitLink(parentCommitRight, false)}.
+          `
+        )}
+      `;
+    }
+
+    // By now we know that we have different commit, same target branch, no weird parent,
+    // and not a standard rebase. So let's spell out what the left and right side are based on.
+    return this.renderWarning(
+      'warning',
+      html`${this.renderInfo(this.baseRevision)}<br />${this.renderInfo(
+          this.revision
+        )}`
+    );
+  }
+
+  private renderInfo(rev: RevisionInfo) {
+    const parent = rev.parents_data?.[0];
+    if (!parent) return;
+    const ps = rev._number;
+    const isMerged = parent.is_merged_in_target_branch;
+    const changeNum = parent.change_number;
+
+    if (changeNum && !isMerged) {
+      return html`
+        Patchset ${ps} is based on patchset
+        ${this.renderPatchsetLink(changeNum, parent.patch_set_number)} of change
+        ${this.renderChangeLink(changeNum)}.
+      `;
+    } else {
+      return html`
+        Patchset ${ps} is based on commit
+        ${this.renderCommitLink(parent.commit_id, false)} in the target branch
+        (${branchName(parent.branch_name)}).
+      `;
+    }
+  }
+
+  private renderWarning(icon: string, message: HTMLTemplateResult) {
+    const isWarning = icon === 'warning';
+    return html`
+      <div class="messageContainer ${icon}">
+        <div class="icon">
+          <gr-icon icon=${icon}></gr-icon>
+        </div>
+        <div class="text">
+          <p>
+            ${message}${when(
+              isWarning,
+              () => html`
+                <br />
+                The diff below may not be meaningful and may <br />
+                even be hiding relevant changes.
+                <a
+                  href=${getDocUrl(
+                    this.docsBaseUrl,
+                    'user-review-ui.html#hazardous-rebases'
+                  )}
+                  >Learn more</a
+                >
+              `
+            )}
+          </p>
+          ${when(
+            isWarning,
+            () => html`
+              <p>
+                <gr-button
+                  link
+                  @click=${() => (this.showDetails = !this.showDetails)}
+                  >${this.showDetails ? 'Hide' : 'Show'} details</gr-button
+                >
+              </p>
+            `
+          )}
+        </div>
+      </div>
+    `;
+  }
+
+  private renderDetails() {
+    if (!this.showDetails) return;
+    if (!this.baseRevision || !this.revision) return;
+    const parentLeft = this.baseRevision.parents_data?.[0];
+    const parentRight = this.revision.parents_data?.[0];
+    if (!parentRight || !parentLeft) return;
+
+    return html`
+      <div class="container">
+        <div class="sections">
+          ${this.renderSection(
+            this.baseRevision,
+            parentLeft,
+            parentLeft.change_number === parentRight.change_number
+          )}
+          ${this.renderSection(
+            this.revision,
+            parentRight,
+            parentLeft.change_number === parentRight.change_number
+          )}
+        </div>
+      </div>
+    `;
+  }
+
+  private renderCommitLink(commit?: CommitId, showCopyButton = true) {
+    if (!commit) return;
+    return html`<gr-commit-info
+      .commitInfo=${{commit}}
+      .showCopyButton=${showCopyButton}
+    ></gr-commit-info>`;
+  }
+
+  private renderChangeLink(changeNum: NumericChangeId) {
+    return html`
+      <a href=${createChangeUrl({changeNum, repo: this.repo!})}>${changeNum}</a>
+    `;
+  }
+
+  private renderPatchsetLink(
+    changeNum: NumericChangeId,
+    patchNum?: PatchSetNumber
+  ) {
+    if (!patchNum) return;
+    return html`
+      <a
+        href=${createChangeUrl({
+          changeNum,
+          repo: this.repo!,
+          patchNum,
+        })}
+        >${patchNum}</a
+      >
+    `;
+  }
+
+  private renderSection(
+    revision: RevisionInfo,
+    parent: ParentInfo,
+    sameChange: boolean
+  ) {
+    const ps = revision._number;
+    const commit = parent.commit_id;
+    const branch = branchName(parent.branch_name);
+    const isMerged = parent.is_merged_in_target_branch;
+    const changeNum = parent.change_number as NumericChangeId;
+    const changePs = parent.patch_set_number;
+
+    createChangeUrl({changeNum, repo: this.repo!});
+
+    return html`
+      <div class="section">
+        <h4 class="heading-4">Patchset ${ps}</h4>
+        <div>Target branch: ${branch}</div>
+        <div>Base commit: ${this.renderCommitLink(commit)}</div>
+        ${when(
+          !changeNum && !isMerged,
+          () => html`
+            <div>
+              <gr-icon icon="warning"></gr-icon>
+              <span
+                >Warning: The base commit is not known (aka reachable) in the
+                target branch.</span
+              >
+            </div>
+          `
+        )}
+        ${when(
+          changeNum && (sameChange || !isMerged),
+          () => html`
+            <div>
+              Base change: ${this.renderChangeLink(changeNum)}, patchset
+              ${this.renderPatchsetLink(changeNum, changePs)}
+              ${when(isMerged, () => html`(MERGED)`)}
+            </div>
+          `
+        )}
+      </div>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-revision-parents': GrRevisionParents;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents_test.ts b/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents_test.ts
new file mode 100644
index 0000000..b9aa63d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-revision-parents/gr-revision-parents_test.ts
@@ -0,0 +1,241 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-revision-parents';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrRevisionParents} from './gr-revision-parents';
+import {createRevision} from '../../../test/test-data-generators';
+import {
+  ChangeId,
+  ChangeStatus,
+  CommitId,
+  NumericChangeId,
+  ParentInfo,
+  PatchSetNumber,
+} from '../../../api/rest-api';
+import {queryAll} from '../../../utils/common-util';
+
+const PARENT_DEFAULT: ParentInfo = {
+  branch_name: 'refs/heads/master',
+  commit_id: '78e52ce873b1c08396422f51ad6aacf77ed95541' as CommitId,
+  is_merged_in_target_branch: true,
+};
+
+const PARENT_REBASED: ParentInfo = {
+  ...PARENT_DEFAULT,
+  commit_id: '00002ce873b1c08396422f51ad6aacf77ed95541' as CommitId,
+};
+
+const PARENT_OTHER_BRANCH: ParentInfo = {
+  ...PARENT_DEFAULT,
+  branch_name: 'refs/heads/otherbranch',
+  commit_id: '11112ce873b1c08396422f51ad6aacf77ed95541' as CommitId,
+};
+
+const PARENT_WEIRD: ParentInfo = {
+  ...PARENT_DEFAULT,
+  commit_id: '22222ce873b1c08396422f51ad6aacf77ed95541' as CommitId,
+  is_merged_in_target_branch: false,
+};
+
+const PARENT_CHANGE_123_1: ParentInfo = {
+  ...PARENT_DEFAULT,
+  commit_id: '12312ce873b1c08396422f51ad6aacf77ed95541' as CommitId,
+  is_merged_in_target_branch: false,
+  change_id: 'Idc69e6d7bba0ce0a9a0bdcd22adb506c0b76e628' as ChangeId,
+  change_number: 123 as NumericChangeId,
+  patch_set_number: 1 as PatchSetNumber,
+  change_status: ChangeStatus.NEW,
+};
+
+const PARENT_CHANGE_123_2: ParentInfo = {
+  ...PARENT_CHANGE_123_1,
+  commit_id: '12322ce873b1c08396422f51ad6aacf77ed95541' as CommitId,
+  patch_set_number: 2 as PatchSetNumber,
+};
+
+suite('gr-revision-parents tests', () => {
+  let element: GrRevisionParents;
+
+  const setParents = async (
+    parentLeft: ParentInfo,
+    parentRight: ParentInfo
+  ) => {
+    element.baseRevision = {
+      ...createRevision(1),
+      parents_data: [parentLeft],
+    };
+    element.revision = {
+      ...createRevision(2),
+      parents_data: [parentRight],
+    };
+    await element.updateComplete;
+  };
+
+  setup(async () => {
+    element = await fixture(html`<gr-revision-parents></gr-revision-parents>`);
+    await element.updateComplete;
+  });
+
+  test('render empty', () => {
+    assert.shadowDom.equal(element, '');
+  });
+
+  test('render details: PARENT_DEFAULT', async () => {
+    element.showDetails = true;
+    await setParents(PARENT_DEFAULT, PARENT_DEFAULT);
+    assert.dom.equal(
+      queryAll(element, '.section')[0],
+      /* HTML */ `
+        <div class="section">
+          <h4 class="heading-4">Patchset 1</h4>
+          <div>Target branch: master</div>
+          <div>
+            Base commit:
+            <gr-commit-info> </gr-commit-info>
+          </div>
+        </div>
+      `
+    );
+  });
+
+  test('render details: PARENT_WEIRD', async () => {
+    element.showDetails = true;
+    await setParents(PARENT_WEIRD, PARENT_WEIRD);
+    assert.dom.equal(
+      queryAll(element, '.section')[0],
+      `
+        <div class="section">
+          <h4 class="heading-4">Patchset 1</h4>
+          <div>Target branch: master</div>
+          <div>
+            Base commit:
+            <gr-commit-info> </gr-commit-info>
+          </div>
+          <div>
+            <gr-icon icon="warning"> </gr-icon>
+            <span>
+              Warning: The base commit is not known (aka reachable) in the
+                target branch.
+            </span>
+          </div>
+        </div>
+      `
+    );
+  });
+
+  test('render details: PARENT_CHANGE_123_1', async () => {
+    element.showDetails = true;
+    await setParents(PARENT_CHANGE_123_1, PARENT_CHANGE_123_2);
+    assert.dom.equal(
+      queryAll(element, '.section')[0],
+      /* HTML */ `
+        <div class="section">
+          <h4 class="heading-4">Patchset 1</h4>
+          <div>Target branch: master</div>
+          <div>
+            Base commit:
+            <gr-commit-info> </gr-commit-info>
+          </div>
+          <div>
+            Base change:
+            <a href="/c/123"> 123 </a>
+            , patchset
+            <a href="/c/123/1"> 1 </a>
+          </div>
+        </div>
+      `
+    );
+  });
+
+  test('render message PARENT_DEFAULT vs PARENT_DEFAULT', async () => {
+    await setParents(PARENT_DEFAULT, PARENT_DEFAULT);
+    assert.shadowDom.equal(element, '');
+  });
+
+  test('render message PARENT_DEFAULT vs PARENT_OTHER_BRANCH', async () => {
+    await setParents(PARENT_DEFAULT, PARENT_OTHER_BRANCH);
+    assert.shadowDom.equal(
+      element,
+      `<div class="messageContainer warning">
+       <div class="icon"><gr-icon icon="warning"></gr-icon></div>
+       <div class="text"><p>
+       Patchset 1 and 2 are targeting different branches.<br/>
+       The diff below may not be meaningful and may<br/>
+       even be hiding relevant changes.
+       <a href="/Documentation/user-review-ui.html#hazardous-rebases">Learn more</a>
+       </p><p><gr-button link="">Show details</gr-button></p></div></div>`
+    );
+  });
+
+  test('render message PARENT_DEFAULT vs PARENT_WEIRD', async () => {
+    await setParents(PARENT_DEFAULT, PARENT_WEIRD);
+    assert.shadowDom.equal(
+      element,
+      `<div class="messageContainer warning">
+       <div class="icon"><gr-icon icon="warning"></gr-icon></div>
+       <div class="text"><p>
+       Patchset 2 is based on a commit that neither exists in its
+            target branch, nor is it a commit of another active change.<br/>
+            The diff below may not be meaningful and may<br/>
+            even be hiding relevant changes.
+            <a href="/Documentation/user-review-ui.html#hazardous-rebases">Learn more</a>
+            </p><p><gr-button link="">Show details</gr-button></p></div></div>`
+    );
+  });
+
+  test('render message PARENT_DEFAULT vs PARENT_REBASED', async () => {
+    await setParents(PARENT_DEFAULT, PARENT_REBASED);
+    assert.shadowDom.equal(
+      element,
+      `<div class="messageContainer info">
+       <div class="icon"><gr-icon icon="info"></gr-icon></div>
+       <div class="text"><p>
+       The change was rebased from <gr-commit-info></gr-commit-info>
+       onto <gr-commit-info></gr-commit-info>.
+       </p></div></div>`
+    );
+  });
+
+  test('render message PARENT_CHANGE_123_1 vs PARENT_CHANGE_123_2', async () => {
+    await setParents(PARENT_CHANGE_123_1, PARENT_CHANGE_123_2);
+    assert.shadowDom.equal(
+      element,
+      `<div class="messageContainer info">
+       <div class="icon"><gr-icon icon="info"></gr-icon></div>
+       <div class="text"><p>
+       The change was rebased from patchset
+       <a href="/c/123/1">1</a> onto
+            patchset
+       <a href="/c/123/2">2</a> of
+            change
+       <a href="/c/123">123</a>.
+       </p></div></div>`
+    );
+  });
+
+  test('render message PARENT_DEFAULT vs PARENT_CHANGE_123_1', async () => {
+    await setParents(PARENT_DEFAULT, PARENT_CHANGE_123_1);
+    assert.shadowDom.equal(
+      element,
+      `<div class="messageContainer warning">
+       <div class="icon"><gr-icon icon="warning"></gr-icon></div>
+       <div class="text"><p>
+       Patchset 1 is based on commit
+       <gr-commit-info></gr-commit-info>
+       in the target branch
+        (master).<br>
+       Patchset 2 is based on patchset
+       <a href="/c/123/1">1</a>
+       of change
+       <a href="/c/123">123</a>.<br>
+       The diff below may not be meaningful and may<br/>
+       even be hiding relevant changes.
+       <a href="/Documentation/user-review-ui.html#hazardous-rebases">Learn more</a>
+       </p><p><gr-button link="">Show details</gr-button></p></div></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 640b9d0..29a22a3 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
@@ -42,7 +42,6 @@
 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';
 
 /**
@@ -111,14 +110,15 @@
           white-space: nowrap;
           vertical-align: top;
         }
-        .votes-cell {
+        .votes {
+          display: flex;
+          flex-flow: column;
+          row-gap: var(--spacing-s);
+        }
+        .votes-line {
           display: flex;
           flex-flow: wrap;
         }
-        .votes-cell .separator {
-          width: 100%;
-          margin-top: var(--spacing-s);
-        }
         gr-vote-chip {
           margin-right: var(--spacing-s);
         }
@@ -280,16 +280,21 @@
       hasVotes(allLabels[label])
     );
 
-    return html`${join(
-      map(
+    return html`<div class="votes">
+      ${map(
         associatedLabelsWithVotes,
         label =>
-          html`${this.renderLabelVote(label, allLabels)}
-          ${this.renderOverrideLabels(requirement, label)}`
-      ),
-      html`<span class="separator"></span>`
-    )}
-    ${this.renderChecks(requirement)}`;
+          html`<div class="votes-line">
+            ${this.renderLabelVote(label, allLabels)}
+            ${this.renderOverrideLabels(
+              requirement,
+              label,
+              associatedLabelsWithVotes.length > 1
+            )}
+            ${this.renderChecks(requirement, label)}
+          </div>`
+      )}
+    </div> `;
   }
 
   renderLabelVote(label: string, labels: LabelNameToInfoMap) {
@@ -317,12 +322,17 @@
 
   renderOverrideLabels(
     requirement: SubmitRequirementResultInfo,
-    forLabel: string
+    forLabel: string,
+    showForAllLabel: boolean
   ) {
-    if (requirement.status !== SubmitRequirementStatus.OVERRIDDEN) return;
+    if (
+      !showForAllLabel &&
+      requirement.status !== SubmitRequirementStatus.OVERRIDDEN
+    )
+      return;
     const requirementLabels = extractAssociatedLabels(
       requirement,
-      'onlyOverride'
+      showForAllLabel ? 'all' : 'onlyOverride'
     )
       .filter(label => label === forLabel)
       .filter(label => {
@@ -334,13 +344,17 @@
     );
   }
 
-  renderChecks(requirement: SubmitRequirementResultInfo) {
+  renderChecks(requirement: SubmitRequirementResultInfo, labelName?: string) {
     const requirementLabels = extractAssociatedLabels(requirement);
     const errorRuns = this.runs
       .filter(run => hasResultsOf(run, Category.ERROR))
-      .filter(
-        run => run.labelName && requirementLabels.includes(run.labelName)
-      );
+      .filter(run => {
+        if (labelName) {
+          return labelName === run.labelName;
+        } else {
+          return run.labelName && requirementLabels.includes(run.labelName);
+        }
+      });
     const errorRunsCount = errorRuns.reduce(
       (sum, run) => sum + getResultsOf(run, Category.ERROR).length,
       0
@@ -357,9 +371,13 @@
       .filter(
         r => r.status === RunStatus.RUNNING || r.status === RunStatus.SCHEDULED
       )
-      .filter(
-        run => run.labelName && requirementLabels.includes(run.labelName)
-      );
+      .filter(run => {
+        if (labelName) {
+          return labelName === run.labelName;
+        } else {
+          return run.labelName && requirementLabels.includes(run.labelName);
+        }
+      });
 
     const runningRunsCount = runningRuns.length;
     if (runningRunsCount > 0) {
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
index 212394c..a5a336c 100644
--- 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
@@ -102,7 +102,11 @@
                 >
                   <gr-endpoint-param name="change"></gr-endpoint-param>
                   <gr-endpoint-param name="requirement"></gr-endpoint-param>
-                  <gr-vote-chip></gr-vote-chip>
+                  <div class="votes">
+                    <div class="votes-line">
+                      <gr-vote-chip> </gr-vote-chip>
+                    </div>
+                  </div>
                 </gr-endpoint-decorator>
               </td>
             </tr>
@@ -125,7 +129,11 @@
         votesCell?.[0],
         /* HTML */ `
           <div class="votes-cell">
-            <gr-vote-chip> </gr-vote-chip>
+            <div class="votes">
+              <div class="votes-line">
+                <gr-vote-chip> </gr-vote-chip>
+              </div>
+            </div>
           </div>
         `
       );
@@ -174,8 +182,12 @@
         votesCell?.[0],
         /* HTML */ `
           <div class="votes-cell">
-            <gr-vote-chip></gr-vote-chip>
-            <gr-checks-chip></gr-checks-chip>
+            <div class="votes">
+              <div class="votes-line">
+                <gr-vote-chip> </gr-vote-chip>
+                <gr-checks-chip> </gr-checks-chip>
+              </div>
+            </div>
           </div>
         `
       );
@@ -196,8 +208,12 @@
         votesCell?.[0],
         /* HTML */ `
           <div class="votes-cell">
-            <gr-vote-chip></gr-vote-chip>
-            <gr-checks-chip></gr-checks-chip>
+            <div class="votes">
+              <div class="votes-line">
+                <gr-vote-chip> </gr-vote-chip>
+                <gr-checks-chip> </gr-checks-chip>
+              </div>
+            </div>
           </div>
         `
       );
@@ -231,8 +247,12 @@
       assert.dom.equal(
         votesCell?.[0],
         /* HTML */ `<div class="votes-cell">
-          <gr-vote-chip> </gr-vote-chip>
-          <span class="overrideLabel"> Override </span>
+          <div class="votes">
+            <div class="votes-line">
+              <gr-vote-chip> </gr-vote-chip>
+              <span class="overrideLabel"> Override </span>
+            </div>
+          </div>
         </div>`
       );
     });
@@ -274,11 +294,16 @@
       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 class="votes">
+            <div class="votes-line">
+              <gr-vote-chip> </gr-vote-chip>
+              <span class="overrideLabel"> Override </span>
+            </div>
+            <div class="votes-line">
+              <gr-vote-chip> </gr-vote-chip>
+              <span class="overrideLabel"> Override2 </span>
+            </div>
+          </div>
         </div>`
       );
     });
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 75845f6..5c40050 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
@@ -96,7 +96,7 @@
     return specialFilePathCompare(c1.path, c2.path);
   }
 
-  // Convert 'FILE' and 'LOST' to undefined.
+  // 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) {
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
index a4357bb..3a06f8d 100644
--- 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
@@ -41,6 +41,7 @@
 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';
+import {FILE} from '../../../api/diff';
 
 suite('gr-thread-list tests', () => {
   let element: GrThreadList;
@@ -665,7 +666,7 @@
 
   test('file level comment before line', () => {
     t1.line = 123;
-    t2.line = 'FILE';
+    t2.line = FILE;
     checkOrder([t2, t1]);
   });
 
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 db49e8d..d35855d 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
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../shared/gr-icon/gr-icon';
+import '../../shared/gr-formatted-text/gr-formatted-text';
 import {customElement, property} from 'lit/decorators.js';
 import {css, html, LitElement} from 'lit';
 import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 1ff7688..1057664 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -47,9 +47,10 @@
   otherPrimaryLinks,
   secondaryLinks,
   tooltipForLink,
+  computeIsExpandable,
 } from '../../models/checks/checks-util';
 import {assertIsDefined, assert, unique} from '../../utils/common-util';
-import {modifierPressed, toggleClass, whenVisible} from '../../utils/dom-util';
+import {modifierPressed, whenVisible} from '../../utils/dom-util';
 import {durationString} from '../../utils/date-util';
 import {charsOnly} from '../../utils/string-util';
 import {isAttemptSelected, matches} from './gr-checks-util';
@@ -78,6 +79,7 @@
 import {DropdownItem} from '../shared/gr-dropdown-list/gr-dropdown-list';
 import './gr-checks-attempt';
 import {createDiffUrl, changeViewModelToken} from '../../models/views/change';
+import {formStyles} from '../../styles/form-styles';
 
 /**
  * Firing this event sets the regular expression of the results filter.
@@ -322,20 +324,12 @@
     ];
   }
 
-  override updated(changedProperties: PropertyValues) {
+  override willUpdate(changedProperties: PropertyValues) {
     if (changedProperties.has('result')) {
-      this.isExpandable = this.computeIsExpandable();
+      this.isExpandable = computeIsExpandable(this.result);
     }
   }
 
-  private computeIsExpandable() {
-    const hasSummary = !!this.result?.summary;
-    const hasMessage = !!this.result?.message;
-    const hasMultipleLinks = (this.result?.links ?? []).length > 1;
-    const hasPointers = (this.result?.codePointers ?? []).length > 0;
-    return hasSummary && (hasMessage || hasMultipleLinks || hasPointers);
-  }
-
   override focus() {
     if (this.nameEl) this.nameEl.focus();
   }
@@ -529,7 +523,11 @@
     if (!link) return;
     const tooltipText = link.tooltip ?? tooltipForLink(link.icon);
     const icon = iconForLink(link.icon);
-    return html`<a href=${link.url} class="link" target="_blank"
+    return html`<a
+      href=${link.url}
+      class="link"
+      target="_blank"
+      rel="noopener noreferrer"
       ><gr-icon
         icon=${icon.name}
         ?filled=${icon.filled}
@@ -560,7 +558,7 @@
         horizontal-align="right"
         @tap-item=${this.handleAction}
         @opened-changed=${(e: ValueChangedEvent<boolean>) =>
-          toggleClass(this, 'dropdown-open', e.detail.value)}
+          this.classList.toggle('dropdown-open', e.detail.value)}
         ?hidden=${overflowItems.length === 0}
         .items=${overflowItems}
         .disabledIds=${disabledItems}
@@ -717,6 +715,7 @@
           changeNum: change._number,
           repo: change.project,
           patchNum: patchset,
+          checksPatchset: patchset,
           diffView: {path, lineNum: line},
         }),
         primary: true,
@@ -732,7 +731,11 @@
     const text = link.tooltip ?? tooltipForLink(link.icon);
     const target = targetBlank ? '_blank' : undefined;
     const icon = iconForLink(link.icon);
-    return html`<a href=${link.url} target=${ifDefined(target)}>
+    return html`<a
+      href=${link.url}
+      target=${ifDefined(target)}
+      rel="noopener noreferrer"
+    >
       <gr-icon icon=${icon.name} class="link" ?filled=${icon.filled}></gr-icon>
       <span>${text}</span>
     </a>`;
@@ -866,6 +869,7 @@
 
   static override get styles() {
     return [
+      formStyles,
       sharedStyles,
       spinnerStyles,
       fontStyles,
@@ -1128,8 +1132,8 @@
               ></gr-dropdown-list>`
             )}
             <gr-dropdown-list
-              value=${this.checksPatchsetNumber ??
-              this.latestPatchsetNumber ??
+              value=${(this.checksPatchsetNumber ||
+                this.latestPatchsetNumber) ??
               0}
               .items=${this.createPatchsetDropdownItems()}
               @value-change=${this.onPatchsetSelected}
@@ -1198,7 +1202,7 @@
     if (!link) return;
     const tooltipText = link.tooltip ?? tooltipForLink(link.icon);
     const icon = iconForLink(link.icon);
-    return html`<a href=${link.url} target="_blank"
+    return html`<a href=${link.url} target="_blank" rel="noopener noreferrer"
       ><gr-icon
         icon=${icon.name}
         aria-label=${tooltipText}
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 8affccb..a349b58 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
@@ -66,7 +66,10 @@
         <div class="space"></div>
       </div>
         <div class="summary-cell">
-          <a class="link" href="https://www.google.com" target="_blank">
+          <a class="link"
+             href="https://www.google.com"
+             target="_blank"
+             rel="noopener noreferrer">
             <gr-icon
               icon="open_in_new"
               aria-label="external link to details"
@@ -194,7 +197,11 @@
               </div>
             </div>
             <div class="right">
-              <a href="https://www.google.com" target="_blank">
+              <a
+                href="https://www.google.com"
+                target="_blank"
+                rel="noopener noreferrer"
+              >
                 <gr-icon
                   icon="bug_report"
                   filled
@@ -203,7 +210,11 @@
                 ></gr-icon>
                 <paper-tooltip offset="5"> </paper-tooltip>
               </a>
-              <a href="https://www.google.com" target="_blank">
+              <a
+                href="https://www.google.com"
+                target="_blank"
+                rel="noopener noreferrer"
+              >
                 <gr-icon
                   icon="open_in_new"
                   aria-label="Fake Link 1"
@@ -211,12 +222,20 @@
                 ></gr-icon>
                 <paper-tooltip offset="5"> </paper-tooltip>
               </a>
-              <a href="https://www.google.com" target="_blank">
+              <a
+                href="https://www.google.com"
+                target="_blank"
+                rel="noopener noreferrer"
+              >
                 <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">
+              <a
+                href="https://www.google.com"
+                target="_blank"
+                rel="noopener noreferrer"
+              >
                 <gr-icon
                   icon="image"
                   filled
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 8ba9895..dd2b29f 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -23,7 +23,6 @@
   iconForRun,
   PRIMARY_STATUS_ACTIONS,
   primaryRunAction,
-  worstCategory,
 } from '../../models/checks/checks-util';
 import {
   CheckRun,
@@ -58,11 +57,13 @@
 import {Deduping} from '../../api/reporting';
 import {when} from 'lit/directives/when.js';
 import {changeViewModelToken} from '../../models/views/change';
+import {formStyles} from '../../styles/form-styles';
 
 @customElement('gr-checks-run')
 export class GrChecksRun extends LitElement {
   static override get styles() {
     return [
+      formStyles,
       sharedStyles,
       css`
         :host {
@@ -331,7 +332,11 @@
     const link = this.run.statusLink;
     if (!link) return;
     return html`
-      <a href=${link} target="_blank" @click=${this.onLinkClick}
+      <a
+        href=${link}
+        target="_blank"
+        rel="noopener noreferrer"
+        @click=${this.onLinkClick}
         ><gr-icon
           icon="open_in_new"
           class="statusLinkIcon"
@@ -362,7 +367,7 @@
    */
   renderAdditionalIcon() {
     if (this.run.status !== RunStatus.RUNNING) return nothing;
-    const category = worstCategory(this.run);
+    const category = this.run.worstCategory;
     if (!category) return nothing;
     const icon = iconFor(category);
     return html`
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
index efc6efe..58b939c 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
@@ -9,6 +9,7 @@
 import {customElement, property, state} from 'lit/decorators.js';
 import {RunResult} from '../../models/checks/checks-model';
 import {
+  computeIsExpandable,
   createFixAction,
   createPleaseFixComment,
   iconFor,
@@ -54,6 +55,9 @@
       fontStyles,
       css`
         .container {
+          /* Allows hiding the check results along with the comments
+             when the user presses the keyboard shortcut 'h'. */
+          display: var(--gr-comment-thread-display, block);
           font-family: var(--font-family);
           margin: 0 var(--spacing-s) var(--spacing-s);
           background-color: var(--unresolved-comment-background-color);
@@ -209,7 +213,7 @@
   private renderActions() {
     if (!this.isExpanded) return nothing;
     return html`<div class="actions">
-      ${this.renderPleaseFixButton()}${this.renderShowFixButton()}
+      ${this.renderShowFixButton()}${this.renderPleaseFixButton()}
     </div>`;
   }
 
@@ -244,9 +248,9 @@
     `;
   }
 
-  override updated(changedProperties: PropertyValues) {
+  override willUpdate(changedProperties: PropertyValues) {
     if (changedProperties.has('result')) {
-      this.isExpandable = !!this.result?.summary && !!this.result?.message;
+      this.isExpandable = computeIsExpandable(this.result);
     }
   }
 
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
index 0377e0e..b913c87 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
@@ -45,6 +45,13 @@
             There is a lot to be said. A lot. I say, a lot.
                 So please keep reading.
           </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>
         <div class="details"></div>
       </div>
@@ -65,11 +72,11 @@
           <gr-result-expanded hidecodepointers=""></gr-result-expanded>
           <div class="actions">
             <gr-checks-action
-              id="please-fix"
+              id="show-fix"
               context="diff-fix"
             ></gr-checks-action>
             <gr-checks-action
-              id="show-fix"
+              id="please-fix"
               context="diff-fix"
             ></gr-checks-action>
           </div>
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
index e78d131..d1ef25b 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
@@ -7,13 +7,12 @@
 import {fontStyles} from '../../styles/gr-font-styles';
 import {customElement, property} from 'lit/decorators.js';
 import './gr-checks-action';
-import {CheckRun} from '../../models/checks/checks-model';
+import {CheckRun, RunResult} from '../../models/checks/checks-model';
 import {
   AttemptDetail,
   ChecksIcon,
   iconFor,
   runActions,
-  worstCategory,
 } from '../../models/checks/checks-util';
 import {durationString, fromNow} from '../../utils/date-util';
 import {RunStatus} from '../../api/checks';
@@ -28,7 +27,7 @@
 @customElement('gr-hovercard-run')
 export class GrHovercardRun extends base {
   @property({type: Object})
-  run?: CheckRun;
+  run?: RunResult | CheckRun;
 
   static override get styles() {
     return [
@@ -171,7 +170,10 @@
             ? html` <div class="row">
                 <div class="title">Status</div>
                 <div>
-                  <a href=${this.run.statusLink} target="_blank"
+                  <a
+                    href=${this.run.statusLink}
+                    target="_blank"
+                    rel="noopener noreferrer"
                     ><gr-icon
                       icon="open_in_new"
                       aria-label="external link to check status"
@@ -317,7 +319,10 @@
             ? html` <div class="row">
                 <div class="title">Documentation</div>
                 <div>
-                  <a href=${this.run.checkLink} target="_blank"
+                  <a
+                    href=${this.run.checkLink}
+                    target="_blank"
+                    rel="noopener noreferrer"
                     ><gr-icon
                       icon="open_in_new"
                       aria-label="external link to check documentation"
@@ -351,8 +356,7 @@
 
   computeIcon(): ChecksIcon {
     if (!this.run) return {name: ''};
-    const category = worstCategory(this.run);
-    if (category) return iconFor(category);
+    if (this.run.worstCategory) return iconFor(this.run.worstCategory);
     return this.run.status === RunStatus.COMPLETED
       ? iconFor(RunStatus.COMPLETED)
       : {name: ''};
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 4ae2a46..e9fbeda 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
@@ -62,7 +62,11 @@
               <div class="row">
                 <div class="title">Status</div>
                 <div>
-                  <a href="https://www.google.com" target="_blank">
+                  <a
+                    href="https://www.google.com"
+                    target="_blank"
+                    rel="noopener noreferrer"
+                  >
                     <gr-icon
                       aria-label="external link to check status"
                       class="link small"
@@ -171,7 +175,11 @@
               <div class="row">
                 <div class="title">Documentation</div>
                 <div>
-                  <a href="https://www.google.com" target="_blank">
+                  <a
+                    href="https://www.google.com"
+                    target="_blank"
+                    rel="noopener noreferrer"
+                  >
                     <gr-icon
                       aria-label="external link to check documentation"
                       class="link small"
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 ca0480c9..779aecf 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
@@ -6,8 +6,11 @@
 import '../../shared/gr-dialog/gr-dialog';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, html, css} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
+import {customElement, property, state} from 'lit/decorators.js';
 import {fireNoBubbleNoCompose} from '../../../utils/event-util';
+import {configModelToken} from '../../../models/config/config-model';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -30,15 +33,29 @@
   @property({type: String})
   text?: string;
 
-  @property({type: String})
-  loginUrl = '/login';
+  @state() loginUrl = '';
 
-  @property({type: String})
-  loginText = 'Sign in';
+  @state() loginText = '';
 
   @property({type: Boolean})
   showSignInButton = false;
 
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().loginUrl$,
+      url => (this.loginUrl = url)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().loginText$,
+      text => (this.loginText = text)
+    );
+  }
+
   static override get styles() {
     return [
       sharedStyles,
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 c8a1c39..fc59980 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
@@ -22,7 +22,7 @@
 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 {customElement, 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';
@@ -110,12 +110,6 @@
    */
   @state() lastCredentialCheck: number = Date.now();
 
-  @property({type: String})
-  loginUrl = '/login';
-
-  @property({type: String})
-  loginText = 'Sign in';
-
   private readonly reporting = getAppContext().reportingService;
 
   private readonly getAuthService = resolve(this, authServiceToken);
@@ -166,8 +160,6 @@
         <gr-error-dialog
           id="errorDialog"
           @dismiss=${() => this.errorModal.close()}
-          .loginUrl=${this.loginUrl}
-          .loginText=${this.loginText}
         ></gr-error-dialog>
       </dialog>
       <dialog
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 baa17ca..ae59fb6 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
@@ -3,14 +3,12 @@
  * 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-icon/gr-icon';
 import '../gr-account-dropdown/gr-account-dropdown';
 import '../gr-smart-search/gr-smart-search';
-import {getBaseUrl} from '../../../utils/url-util';
+import {getBaseUrl, getDocUrl} from '../../../utils/url-util';
 import {getAdminLinks, NavLink} from '../../../models/views/admin';
 import {
   AccountDetailInfo,
@@ -30,6 +28,7 @@
 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';
+import {subscribe} from '../../lit/subscription-controller';
 
 type MainHeaderLink = RequireProperties<DropdownLink, 'url' | 'name'>;
 
@@ -86,6 +85,18 @@
   },
 ];
 
+// visible for testing
+export function getDocLinks(docBaseUrl: string, docLinks: MainHeaderLink[]) {
+  if (!docBaseUrl) return [];
+  return docLinks.map(link => {
+    return {
+      url: getDocUrl(docBaseUrl, link.url),
+      name: link.name,
+      target: '_blank',
+    };
+  });
+}
+
 // Set of authentication methods that can provide custom registration page.
 const AUTH_TYPES_WITH_REGISTER_URL: Set<AuthType> = new Set([
   AuthType.LDAP,
@@ -110,11 +121,9 @@
   @property({type: Boolean, reflect: true})
   loading?: boolean;
 
-  @property({type: String})
-  loginUrl = '/login';
+  @state() loginUrl = '';
 
-  @property({type: String})
-  loginText = 'Sign in';
+  @state() loginText = '';
 
   @property({type: Boolean})
   mobileSearchHidden = false;
@@ -124,7 +133,7 @@
 
   @state() private adminLinks: NavLink[] = [];
 
-  @state() private docBaseUrl: string | null = null;
+  @state() private docsBaseUrl = '';
 
   @state() private userLinks: MainHeaderLink[] = [];
 
@@ -148,40 +157,42 @@
 
   private readonly getConfigModel = resolve(this, configModelToken);
 
-  private subscriptions: Subscription[] = [];
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getUserModel().myMenuItems$,
+      items => (this.userLinks = items.map(this.createHeaderLink))
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().loginUrl$,
+      loginUrl => (this.loginUrl = loginUrl)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().loginText$,
+      loginText => (this.loginText = loginText)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().docsBaseUrl$,
+      docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        if (!config) return;
+        this.retrieveFeedbackURL(config);
+        this.retrieveRegisterURL(config);
+      }
+    );
+  }
 
   override connectedCallback() {
     super.connectedCallback();
     this.loadAccount();
-
-    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.retrieveFeedbackURL(config);
-        this.retrieveRegisterURL(config);
-        this.restApiService.getDocsBaseUrl(config).then(docBaseUrl => {
-          this.docBaseUrl = docBaseUrl;
-        });
-      })
-    );
-  }
-
-  override disconnectedCallback() {
-    for (const s of this.subscriptions) {
-      s.unsubscribe();
-    }
-    this.subscriptions = [];
-    super.disconnectedCallback();
   }
 
   static override get styles() {
@@ -368,12 +379,9 @@
       </gr-endpoint-decorator>
     </a>
     <ul class="links">
-      ${this.computeLinks(
-        this.userLinks,
-        this.adminLinks,
-        this.topMenus,
-        this.docBaseUrl
-      ).map(linkGroup => this.renderLinkGroup(linkGroup))}
+      ${this.computeLinks(this.userLinks, this.adminLinks, this.topMenus).map(
+        linkGroup => this.renderLinkGroup(linkGroup)
+      )}
     </ul>
     <div class="rightItems">
       <gr-endpoint-decorator
@@ -421,6 +429,7 @@
         title="File a bug"
         aria-label="File a bug"
         target="_blank"
+        rel="noopener noreferrer"
         role="button"
       >
         <gr-icon icon="bug_report" filled></gr-icon>
@@ -445,7 +454,9 @@
           ></gr-icon>
         </div>
         ${this.renderRegister()}
-        <a class="loginButton" href=${this.loginUrl}>${this.loginText}</a>
+        <gr-endpoint-decorator name="auth-link">
+          <a class="loginButton" href=${this.loginUrl}>${this.loginText}</a>
+        </gr-endpoint-decorator>
         <a
           class="settingsButton"
           href="${getBaseUrl()}/settings/"
@@ -494,15 +505,13 @@
     userLinks?: MainHeaderLink[],
     adminLinks?: NavLink[],
     topMenus?: TopMenuEntryInfo[],
-    docBaseUrl?: string | null,
     // defaultLinks parameter is used in tests only
     defaultLinks = DEFAULT_LINKS
   ) {
     if (
       userLinks === undefined ||
       adminLinks === undefined ||
-      topMenus === undefined ||
-      docBaseUrl === undefined
+      topMenus === undefined
     ) {
       return [];
     }
@@ -519,7 +528,7 @@
         links: userLinks.slice(),
       });
     }
-    const docLinks = this.getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
+    const docLinks = getDocLinks(this.docsBaseUrl, DOCUMENTATION_LINKS);
     if (docLinks.length) {
       links.push({
         title: 'Documentation',
@@ -556,24 +565,6 @@
   }
 
   // private but used in test
-  getDocLinks(docBaseUrl: string | null, docLinks: MainHeaderLink[]) {
-    if (!docBaseUrl) {
-      return [];
-    }
-    return docLinks.map(link => {
-      let url = docBaseUrl;
-      if (url && url[url.length - 1] === '/') {
-        url = url.substring(0, url.length - 1);
-      }
-      return {
-        url: url + link.url,
-        name: link.name,
-        target: '_blank',
-      };
-    });
-  }
-
-  // private but used in test
   loadAccount() {
     this.loading = true;
 
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 6d0c86e..40430fb 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
@@ -11,7 +11,7 @@
   stubRestApi,
 } from '../../../test/test-utils';
 import './gr-main-header';
-import {GrMainHeader} from './gr-main-header';
+import {GrMainHeader, getDocLinks} from './gr-main-header';
 import {
   createAccountDetailWithId,
   createGerritInfo,
@@ -31,6 +31,8 @@
       Promise.resolve()
     );
     element = await fixture(html`<gr-main-header></gr-main-header>`);
+    element.loginUrl = '/login';
+    await element.updateComplete;
   });
 
   test('renders', () => {
@@ -49,6 +51,11 @@
                 <span class="linksTitle" id="Changes"> Changes </span>
               </gr-dropdown>
             </li>
+            <li class="hideOnMobile">
+              <gr-dropdown down-arrow="" horizontal-align="left" link="">
+                <span class="linksTitle" id="Documentation">Documentation</span>
+              </gr-dropdown>
+            </li>
             <li>
               <gr-dropdown down-arrow="" horizontal-align="left" link="">
                 <span class="linksTitle" id="Browse"> Browse </span>
@@ -80,7 +87,9 @@
               >
               </gr-icon>
             </div>
-            <a class="loginButton" href="/login"> Sign in </a>
+            <gr-endpoint-decorator name="auth-link">
+              <a class="loginButton" href="/login"> Sign in </a>
+            </gr-endpoint-decorator>
             <a
               aria-label="Settings"
               class="settingsButton"
@@ -164,36 +173,24 @@
 
     // When no admin links are passed, it should use the default.
     assert.deepEqual(
-      element.computeLinks(
-        /* userLinks= */ [],
-        adminLinks,
-        /* topMenus= */ [],
-        /* docBaseUrl= */ '',
-        defaultLinks
-      ),
-      defaultLinks.concat({
-        title: 'Browse',
-        links: adminLinks,
-      })
+      element
+        .computeLinks(
+          /* userLinks= */ [],
+          adminLinks,
+          /* topMenus= */ [],
+          defaultLinks
+        )
+        .find(i => i.title === 'Faves'),
+      defaultLinks[0]
     );
     assert.deepEqual(
-      element.computeLinks(
-        userLinks,
-        adminLinks,
-        /* topMenus= */ [],
-        /* docBaseUrl= */ '',
-        defaultLinks
-      ),
-      defaultLinks.concat([
-        {
-          title: 'Your',
-          links: userLinks,
-        },
-        {
-          title: 'Browse',
-          links: adminLinks,
-        },
-      ])
+      element
+        .computeLinks(userLinks, adminLinks, /* topMenus= */ [], defaultLinks)
+        .find(i => i.title === 'Your'),
+      {
+        title: 'Your',
+        links: userLinks,
+      }
     );
   });
 
@@ -205,11 +202,10 @@
       },
     ];
 
-    assert.deepEqual(element.getDocLinks(null, docLinks), []);
-    assert.deepEqual(element.getDocLinks('', docLinks), []);
-    assert.deepEqual(element.getDocLinks('base', []), []);
+    assert.deepEqual(getDocLinks('', docLinks), []);
+    assert.deepEqual(getDocLinks('base', []), []);
 
-    assert.deepEqual(element.getDocLinks('base', docLinks), [
+    assert.deepEqual(getDocLinks('base', docLinks), [
       {
         name: 'Table of Contents',
         target: '_blank',
@@ -217,7 +213,7 @@
       },
     ]);
 
-    assert.deepEqual(element.getDocLinks('base/', docLinks), [
+    assert.deepEqual(getDocLinks('base/', docLinks), [
       {
         name: 'Table of Contents',
         target: '_blank',
@@ -251,24 +247,17 @@
         /* userLinks= */ [],
         adminLinks,
         topMenus,
-        /* baseDocUrl= */ '',
         /* defaultLinks= */ []
-      ),
-      [
-        {
-          title: 'Browse',
-          links: adminLinks,
-        },
-        {
-          title: 'Plugins',
-          links: [
-            {
-              name: 'Manage',
-              url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-            },
-          ],
-        },
-      ]
+      )[2],
+      {
+        title: 'Plugins',
+        links: [
+          {
+            name: 'Manage',
+            url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+          },
+        ],
+      }
     );
   });
 
@@ -302,24 +291,17 @@
         /* userLinks= */ [],
         adminLinks,
         topMenus,
-        /* baseDocUrl= */ '',
         /* defaultLinks= */ []
-      ),
-      [
-        {
-          title: 'Browse',
-          links: adminLinks,
-        },
-        {
-          title: 'Projects',
-          links: [
-            {
-              name: 'Project List',
-              url: '/plugins/myplugin/index.html',
-            },
-          ],
-        },
-      ]
+      )[2],
+      {
+        title: 'Projects',
+        links: [
+          {
+            name: 'Project List',
+            url: '/plugins/myplugin/index.html',
+          },
+        ],
+      }
     );
   });
 
@@ -358,28 +340,21 @@
         /* userLinks= */ [],
         adminLinks,
         topMenus,
-        /* baseDocUrl= */ '',
         /* defaultLinks= */ []
-      ),
-      [
-        {
-          title: 'Browse',
-          links: adminLinks,
-        },
-        {
-          title: 'Plugins',
-          links: [
-            {
-              name: 'Manage',
-              url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-            },
-            {
-              name: 'Create',
-              url: 'https://gerrit/plugins/plugin-manager/static/create.html',
-            },
-          ],
-        },
-      ]
+      )[2],
+      {
+        title: 'Plugins',
+        links: [
+          {
+            name: 'Manage',
+            url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+          },
+          {
+            name: 'Create',
+            url: 'https://gerrit/plugins/plugin-manager/static/create.html',
+          },
+        ],
+      }
     );
   });
 
@@ -412,24 +387,17 @@
         /* userLinks= */ [],
         /* adminLinks= */ [],
         topMenus,
-        /* baseDocUrl= */ '',
         defaultLinks
-      ),
-      [
-        {
-          title: 'Faves',
-          links: defaultLinks[0].links.concat([
-            {
-              name: 'Manage',
-              url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-            },
-          ]),
-        },
-        {
-          title: 'Browse',
-          links: [],
-        },
-      ]
+      )[0],
+      {
+        title: 'Faves',
+        links: defaultLinks[0].links.concat([
+          {
+            name: 'Manage',
+            url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+          },
+        ]),
+      }
     );
   });
 
@@ -458,29 +426,22 @@
         userLinks,
         /* adminLinks= */ [],
         topMenus,
-        /* baseDocUrl= */ '',
         /* defaultLinks= */ []
-      ),
-      [
-        {
-          title: 'Your',
-          links: [
-            {
-              name: 'Facebook',
-              url: 'https://facebook.com',
-              target: '',
-            },
-            {
-              name: 'Manage',
-              url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-            },
-          ],
-        },
-        {
-          title: 'Browse',
-          links: [],
-        },
-      ]
+      )[0],
+      {
+        title: 'Your',
+        links: [
+          {
+            name: 'Facebook',
+            url: 'https://facebook.com',
+            target: '',
+          },
+          {
+            name: 'Manage',
+            url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+          },
+        ],
+      }
     );
   });
 
@@ -509,21 +470,18 @@
         /* userLinks= */ [],
         adminLinks,
         topMenus,
-        /* baseDocUrl= */ '',
         /* defaultLinks= */ []
-      ),
-      [
-        {
-          title: 'Browse',
-          links: [
-            adminLinks[0],
-            {
-              name: 'Manage',
-              url: 'https://gerrit/plugins/plugin-manager/static/index.html',
-            },
-          ],
-        },
-      ]
+      )[1],
+      {
+        title: 'Browse',
+        links: [
+          adminLinks[0],
+          {
+            name: 'Manage',
+            url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+          },
+        ],
+      }
     );
   });
 
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 94241ea..3d21e07 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -10,7 +10,12 @@
 export interface NavigationService {
   /**
    * This is similar to letting the browser navigate to this URL when the user
-   * clicks it, or to just setting `window.location.href` directly.
+   * clicks it, or to just calling `window.location.assign()` directly.
+   *
+   * CAUTION: You should actually use `window.location.assign()` directly for
+   * URLs that are not handled by gr-router. Otherwise we will call
+   * `pushState()` and then `window.location.reload()` from the router, which
+   * will break the browser's back button.
    *
    * This adds a new entry to the browser location history. Consier using
    * `replaceUrl()`, if you want to avoid that.
@@ -23,6 +28,11 @@
    * Navigate to this URL, but replace the current URL in the history instead of
    * adding a new one (which is what `setUrl()` would do).
    *
+   * CAUTION: You should actually use `window.location.replace()` directly for
+   * URLs that are not handled by gr-router. Otherwise we will call
+   * `replaceState()` and then `window.location.reload()` from the router, which
+   * will break the browser's back button.
+   *
    * page.redirect() eventually just calls `window.history.replaceState()`.
    */
   replaceUrl(url: string): void;
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
index d06b405..cbbcee0 100644
--- 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
@@ -33,10 +33,7 @@
       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
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-page.ts b/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
index 1d2a272..93859ad 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
@@ -4,6 +4,8 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 
+import {sameOrigin} from '../../../utils/url-util';
+
 /**
  * This file was originally a copy of https://github.com/visionmedia/page.js.
  * It was converted to TypeScript and stripped off lots of code that we don't
@@ -50,6 +52,11 @@
   path?: string;
 }
 
+export const UNHANDLED_URL_PATTERNS = [
+  /^\/log(in|out)(\/(.+))?$/,
+  /^\/plugins\/(.+)$/,
+];
+
 const clickEvent = document.ontouchstart ? 'touchstart' : 'click';
 
 export class Page {
@@ -236,6 +243,18 @@
     if (this.base && orig === path && window.location.protocol !== 'file:') {
       return;
     }
+
+    // See issue 40015337: We have to make sure that we only use
+    // show()/pushState() for URLs that gr-router will actually handle.
+    // Calling pushState() tells the browser that both the previous and the
+    // next URL are handled by the same single page application with a
+    // popstate event handler. But if we call pushState() and then
+    // later `window.location.reload()` from the router and a separate page
+    // and document are loaded, then the BACK button will stop working.
+    if (UNHANDLED_URL_PATTERNS.find(pattern => pattern.test(path))) {
+      return;
+    }
+
     e.preventDefault();
     this.show(orig);
   };
@@ -252,17 +271,6 @@
   };
 }
 
-function sameOrigin(href: string) {
-  if (!href) return false;
-  const url = new URL(href, window.location.toString());
-  const loc = window.location;
-  return (
-    loc.protocol === url.protocol &&
-    loc.hostname === url.hostname &&
-    loc.port === url.port
-  );
-}
-
 function samePath(url: HTMLAnchorElement) {
   const loc = window.location;
   return url.pathname === loc.pathname && url.search === loc.search;
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts
index d194bf55..729a15b 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts
@@ -20,14 +20,49 @@
     page.stop();
   });
 
-  test('click handler', async () => {
-    const spy = sinon.spy();
-    page.registerRoute(/\/settings/, spy);
-    const link = await fixture<HTMLAnchorElement>(
-      html`<a href="/settings"></a>`
-    );
-    link.click();
-    assert.isTrue(spy.calledOnce);
+  suite('click handler', () => {
+    const clickListener = (e: Event) => e.preventDefault();
+    let spy: sinon.SinonSpy;
+    let link: HTMLAnchorElement;
+
+    setup(async () => {
+      spy = sinon.spy();
+      link = await fixture<HTMLAnchorElement>(html`<a href="/settings"></a>`);
+
+      document.addEventListener('click', clickListener);
+    });
+
+    teardown(() => {
+      document.removeEventListener('click', clickListener);
+    });
+
+    test('click handled by specific route', async () => {
+      page.registerRoute(/\/settings/, spy);
+      link.href = '/settings';
+      link.click();
+      assert.isTrue(spy.calledOnce);
+    });
+
+    test('click handled by default route', async () => {
+      page.registerRoute(/.*/, spy);
+      link.href = '/something';
+      link.click();
+      assert.isTrue(spy.called);
+    });
+
+    test('click not handled for /plugins/... links', async () => {
+      page.registerRoute(/.*/, spy);
+      link.href = '/plugins/gitiles';
+      link.click();
+      assert.isFalse(spy.called);
+    });
+
+    test('click not handled for /login/... links', async () => {
+      page.registerRoute(/.*/, spy);
+      link.href = '/login';
+      link.click();
+      assert.isFalse(spy.called);
+    });
   });
 
   test('register route and exit', () => {
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 aa6bb7a4..c52b649 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -70,6 +70,7 @@
   createDiffUrl,
 } from '../../../models/views/change';
 import {
+  DashboardType,
   DashboardViewModel,
   DashboardViewState,
   PROJECT_DASHBOARD_ROUTE,
@@ -79,7 +80,6 @@
   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 {
@@ -96,14 +96,17 @@
   getPatchRangeForCommentUrl,
   isInBaseOfPatchRange,
 } from '../../../utils/comment-util';
-import {isFileUnchanged} from '../../../embed/diff/gr-diff/gr-diff-utils';
+import {isFileUnchanged} from '../../../utils/diff-util';
 import {Route, ViewState} from '../../../models/views/base';
-import {Model} from '../../../models/model';
+import {Model} from '../../../models/base/model';
 import {
   InteractivePromise,
   interactivePromise,
+  noAwait,
   timeoutPromise,
 } from '../../../utils/async-util';
+import {Finalizable} from '../../../types/types';
+import {assign} from '../../../utils/location-util';
 
 // TODO: Move all patterns to view model files and use the `Route` interface,
 // which will enforce using `RegExp` in its `urlPattern` property.
@@ -118,12 +121,6 @@
   NEW_AGREEMENTS: /^\/settings\/new-agreement\/?/,
   REGISTER: /^\/register(\/.*)?$/,
 
-  // Pattern for login and logout URLs intended to be passed-through. May
-  // include a return URL.
-  // TODO: Maybe this pattern and its handler can just be removed, because
-  // passing through is what the default router would eventually do anyway.
-  LOG_IN_OR_OUT: /^\/log(in|out)(\/(.+))?$/,
-
   // Pattern for a catchall route when no other pattern is matched.
   DEFAULT: /.*/,
 
@@ -169,8 +166,6 @@
   // Matches /admin/repos/<repos>,access.
   REPO_DASHBOARDS: /^\/admin\/repos\/(.+),dashboards$/,
 
-  PLUGINS: /^\/plugins\/(.+)$/,
-
   // Matches /admin/plugins with optional filter and offset.
   PLUGIN_LIST: /^\/admin\/plugins\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
   // Matches /admin/groups with optional filter and offset.
@@ -195,10 +190,6 @@
 
   CHANGE_ID_QUERY: /^\/id\/(I[0-9a-f]{40})$/,
 
-  // Matches /c/<changeNum>/[*][/].
-  CHANGE_LEGACY: /^\/c\/(\d+)\/?(.*)$/,
-  CHANGE_NUMBER_LEGACY: /^\/(\d+)\/?/,
-
   // Matches
   // /c/<project>/+/<changeNum>/[<basePatchNum|edit>..][<patchNum|edit>].
   // TODO(kaspern): Migrate completely to project based URLs, with backwards
@@ -458,8 +449,14 @@
    */
   redirectToLogin(returnUrl: string) {
     const basePath = getBaseUrl() || '';
-    this.setUrl(
-      '/login/' + encodeURIComponent(returnUrl.substring(basePath.length))
+    // We are not using `this.getNavigation().setUrl()`, because the login
+    // page is served directly from the backend and is not part of the web
+    // app.
+    assign(
+      window.location,
+      `${basePath}/login/${encodeURIComponent(
+        returnUrl.substring(basePath.length)
+      )}`
     );
   }
 
@@ -506,9 +503,8 @@
 
   /**  gr-page middleware that warms the REST API's logged-in cache line. */
   private loadUserMiddleware(_: PageContext, next: PageNextCallback) {
-    this.restApiService.getLoggedIn().then(() => {
-      next();
-    });
+    noAwait(this.restApiService.getLoggedIn());
+    next();
   }
 
   /**
@@ -582,6 +578,8 @@
    * page.show() eventually just calls `window.history.pushState()`.
    */
   setUrl(url: string) {
+    // TODO: Use window.location.assign() instead of page.show(), if the URL is
+    // external, i.e. not handled by the router.
     this.page.show(url);
   }
 
@@ -592,6 +590,8 @@
    * this.page.redirect() eventually just calls `window.history.replaceState()`.
    */
   replaceUrl(url: string) {
+    // TODO: Use window.location.replace() instead of page.redirect(), if the
+    // URL is external, i.e. not handled by the router.
     this.redirect(url);
   }
 
@@ -808,10 +808,6 @@
       this.handleRepoRoute(ctx)
     );
 
-    this.mapRoute(RoutePattern.PLUGINS, 'handlePassThroughRoute', () =>
-      this.handlePassThroughRoute()
-    );
-
     this.mapRoute(
       RoutePattern.PLUGIN_LIST,
       'handlePluginListFilterRoute',
@@ -849,12 +845,6 @@
     );
 
     this.mapRoute(
-      RoutePattern.CHANGE_NUMBER_LEGACY,
-      'handleChangeNumberLegacyRoute',
-      ctx => this.handleChangeNumberLegacyRoute(ctx)
-    );
-
-    this.mapRoute(
       RoutePattern.DIFF_EDIT,
       'handleDiffEditRoute',
       ctx => this.handleDiffEditRoute(ctx),
@@ -884,10 +874,6 @@
       this.handleChangeRoute(ctx)
     );
 
-    this.mapRoute(RoutePattern.CHANGE_LEGACY, 'handleChangeLegacyRoute', ctx =>
-      this.handleChangeLegacyRoute(ctx)
-    );
-
     this.mapRoute(
       RoutePattern.AGREEMENTS,
       'handleAgreementsRoute',
@@ -920,10 +906,6 @@
       this.handleRegisterRoute(ctx)
     );
 
-    this.mapRoute(RoutePattern.LOG_IN_OR_OUT, 'handlePassThroughRoute', () =>
-      this.handlePassThroughRoute()
-    );
-
     this.mapRoute(
       RoutePattern.IMPROPERLY_ENCODED_PLUS,
       'handleImproperlyEncodedPlusRoute',
@@ -1020,6 +1002,7 @@
       } else {
         const state: DashboardViewState = {
           view: GerritView.DASHBOARD,
+          type: DashboardType.USER,
           user: ctx.params[0],
         };
         // Note that router model view must be updated before view models.
@@ -1055,6 +1038,7 @@
 
     const state: DashboardViewState = {
       view: GerritView.DASHBOARD,
+      type: DashboardType.CUSTOM,
       user: 'self',
       sections,
       title,
@@ -1305,14 +1289,6 @@
     this.redirect(ctx.path.replace(LEGACY_QUERY_SUFFIX_PATTERN, ''));
   }
 
-  handleChangeNumberLegacyRoute(ctx: PageContext) {
-    this.redirect(
-      '/c/' +
-        ctx.params[0] +
-        (ctx.querystring.length > 0 ? `?${ctx.querystring}` : '')
-    );
-  }
-
   handleChangeRoute(ctx: PageContext) {
     // Parameter order is based on the regex group number matched.
     const changeNum = Number(ctx.params[1]) as NumericChangeId;
@@ -1358,6 +1334,7 @@
     const repo = ctx.params[0] as RepoName;
     const commentId = ctx.params[2] as UrlEncodedCommentId;
 
+    this.restApiService.setInProjectLookup(changeNum, repo);
     const [comments, robotComments, drafts, change] = await Promise.all([
       this.restApiService.getDiffComments(changeNum),
       this.restApiService.getDiffRobotComments(changeNum),
@@ -1446,6 +1423,10 @@
       diffView: {path: ctx.params[8]},
     };
     const queryMap = new URLSearchParams(ctx.querystring);
+    const checksPatchset = Number(queryMap.get('checksPatchset'));
+    if (Number.isInteger(checksPatchset) && checksPatchset > 0) {
+      state.checksPatchset = checksPatchset as PatchSetNumber;
+    }
     if (queryMap.has('forceReload')) state.forceReload = true;
     const address = this.parseLineAddress(ctx.hash);
     if (address) {
@@ -1460,26 +1441,6 @@
     this.changeViewModel.setState(state);
   }
 
-  handleChangeLegacyRoute(ctx: PageContext) {
-    const changeNum = Number(ctx.params[0]) as NumericChangeId;
-    if (!changeNum) {
-      this.show404();
-      return;
-    }
-    this.restApiService.getFromProjectLookup(changeNum).then(project => {
-      // Show a 404 and terminate if the lookup request failed. Attempting
-      // to redirect after failing to get the project loops infinitely.
-      if (!project) {
-        this.show404();
-        return;
-      }
-      this.redirect(
-        `/c/${project}/+/${changeNum}/${ctx.params[1]}` +
-          (ctx.querystring.length > 0 ? `?${ctx.querystring}` : '')
-      );
-    });
-  }
-
   handleLegacyLinenum(ctx: PageContext) {
     this.redirect(ctx.path.replace(LEGACY_LINENUM_PATTERN, '#$1'));
   }
@@ -1581,14 +1542,6 @@
   }
 
   /**
-   * Handler for routes that should pass through the router and not be caught
-   * by the catchall _handleDefaultRoute handler.
-   */
-  handlePassThroughRoute() {
-    windowLocationReload();
-  }
-
-  /**
    * URL may sometimes have /+/ encoded to / /.
    * Context: Issue 6888, Issue 7100
    */
@@ -1643,10 +1596,15 @@
       this.show404();
     } else {
       // Route can be recognized by server, so we pass it to server.
-      this.handlePassThroughRoute();
+      this.windowReload();
     }
   }
 
+  // Allows stubbing in tests.
+  windowReload() {
+    windowLocationReload();
+  }
+
   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.
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
index 234bf95..5fe63d7 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -51,6 +51,7 @@
 } from '../../../test/test-data-generators';
 import {ParsedChangeInfo} from '../../../types/types';
 import {ViewState} from '../../../models/views/base';
+import {DashboardType} from '../../../models/views/dashboard';
 
 suite('gr-router tests', () => {
   let router: GrRouter;
@@ -168,19 +169,16 @@
     const unauthenticatedHandlers = [
       'handleBranchListRoute',
       'handleChangeIdQueryRoute',
-      'handleChangeNumberLegacyRoute',
       'handleChangeRoute',
       'handleCommentRoute',
       'handleCommentsRoute',
       'handleDiffRoute',
       'handleDefaultRoute',
-      'handleChangeLegacyRoute',
       'handleDocumentationRedirectRoute',
       'handleDocumentationSearchRoute',
       'handleDocumentationSearchRedirectRoute',
       'handleLegacyLinenum',
       'handleImproperlyEncodedPlusRoute',
-      'handlePassThroughRoute',
       'handleProjectDashboardRoute',
       'handleLegacyProjectDashboardRoute',
       'handleProjectsOldRoute',
@@ -333,7 +331,7 @@
   suite('route handlers', () => {
     let redirectStub: sinon.SinonStub;
     let setStateStub: sinon.SinonStub;
-    let handlePassThroughRoute: sinon.SinonStub;
+    let windowReloadStub: sinon.SinonStub;
     let redirectToLoginStub: sinon.SinonStub;
 
     async function checkUrlToState<T extends ViewState>(
@@ -366,18 +364,12 @@
       assert.equal(redirectToLoginStub.lastCall.firstArg, toUrl);
     }
 
-    async function checkUrlNotMatched(url: string) {
-      handlePassThroughRoute.reset();
-      router.page.show(url);
-      await waitUntilCalled(handlePassThroughRoute, 'handlePassThroughRoute');
-    }
-
     setup(() => {
       stubRestApi('setInProjectLookup');
       redirectStub = sinon.stub(router, 'redirect');
       redirectToLoginStub = sinon.stub(router, 'redirectToLogin');
       setStateStub = sinon.stub(router, 'setState');
-      handlePassThroughRoute = sinon.stub(router, 'handlePassThroughRoute');
+      windowReloadStub = sinon.stub(router, 'windowReload');
       router._testOnly_startRouter();
     });
 
@@ -444,7 +436,7 @@
       onExit!('', () => {}); // we left page;
 
       router.handleDefaultRoute();
-      assert.isTrue(handlePassThroughRoute.calledOnce);
+      assert.isTrue(windowReloadStub.calledOnce);
     });
 
     test('IMPROPERLY_ENCODED_PLUS', async () => {
@@ -591,6 +583,7 @@
         // CUSTOM_DASHBOARD: /^\/dashboard\/?$/,
         await checkUrlToState('/dashboard?title=Custom Dashboard&a=b&d=e', {
           ...createDashboardViewState(),
+          type: DashboardType.CUSTOM,
           sections: [
             {name: 'a', query: 'b'},
             {name: 'd', query: 'e'},
@@ -599,6 +592,7 @@
         });
         await checkUrlToState('/dashboard?a=b&c&d=&=e&foreach=is:open', {
           ...createDashboardViewState(),
+          type: DashboardType.CUSTOM,
           sections: [{name: 'a', query: 'is:open b'}],
           title: 'Custom Dashboard',
         });
@@ -853,21 +847,6 @@
     });
 
     suite('CHANGE* / DIFF*', () => {
-      test('CHANGE_NUMBER_LEGACY', async () => {
-        // CHANGE_NUMBER_LEGACY: /^\/(\d+)\/?/,
-        await checkRedirect('/12345', '/c/12345');
-      });
-
-      test('CHANGE_LEGACY', async () => {
-        // CHANGE_LEGACY: /^\/c\/(\d+)\/?(.*)$/,
-        stubRestApi('getFromProjectLookup').resolves('project' as RepoName);
-        await checkRedirect('/c/1234', '/c/project/+/1234/');
-        await checkRedirect(
-          '/c/1234/comment/6789',
-          '/c/project/+/1234/comment/6789'
-        );
-      });
-
       test('DIFF_LEGACY_LINENUM', async () => {
         await checkRedirect(
           '/c/1234/3..8/foo/bar@321',
@@ -1036,12 +1015,6 @@
       });
     });
 
-    test('LOG_IN_OR_OUT pass through', async () => {
-      // LOG_IN_OR_OUT: /^\/log(in|out)(\/(.+))?$/,
-      await checkUrlNotMatched('/login/asdf');
-      await checkUrlNotMatched('/logout/asdf');
-    });
-
     test('PLUGIN_SCREEN', async () => {
       // PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,
       await checkUrlToState('/x/foo/bar', {
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 0856eed..98e9eba 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
@@ -30,6 +30,7 @@
   ValueChangedEvent,
 } from '../../../types/events';
 import {fireNoBubbleNoCompose} from '../../../utils/event-util';
+import {getDocUrl} from '../../../utils/url-util';
 
 // Possible static search options for auto complete, without negations.
 const SEARCH_OPERATORS: ReadonlyArray<string> = [
@@ -168,7 +169,7 @@
   @state() inputVal = '';
 
   // private but used in test
-  @state() docsBaseUrl: string | null = null;
+  @state() docsBaseUrl = '';
 
   @state() private query: AutocompleteQuery;
 
@@ -240,8 +241,9 @@
           <a
             class="help"
             slot="suffix"
-            href=${this.computeHelpDocLink()}
+            href=${getDocUrl(this.docsBaseUrl, 'user-search.html')}
             target="_blank"
+            rel="noopener noreferrer"
             tabindex="-1"
           >
             <gr-icon icon="help" title="read documentation"></gr-icon>
@@ -275,18 +277,6 @@
     return set;
   }
 
-  // private but used in test
-  computeHelpDocLink() {
-    // fallback to gerrit's official doc
-    let baseUrl =
-      this.docsBaseUrl ||
-      'https://gerrit-review.googlesource.com/Documentation/';
-    if (baseUrl.endsWith('/')) {
-      baseUrl = baseUrl.substring(0, baseUrl.length - 1);
-    }
-    return `${baseUrl}/user-search.html`;
-  }
-
   private handleInputCommit(e: AutocompleteCommitEvent) {
     this.preventDefaultAndNavigateToInputVal(e);
   }
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 603ea7b..f67024f 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
@@ -81,6 +81,7 @@
               slot="suffix"
               tabindex="-1"
               target="_blank"
+              rel="noopener noreferrer"
             >
               <gr-icon icon="help" title="read documentation"></gr-icon>
             </a>
@@ -90,23 +91,6 @@
     );
   });
 
-  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';
     await element.updateComplete;
@@ -302,29 +286,4 @@
       });
     });
   });
-
-  suite('doc url', () => {
-    setup(async () => {
-      element = await fixture(html`<gr-search-bar></gr-search-bar>`);
-    });
-
-    test('compute help doc url with correct path', async () => {
-      element.docsBaseUrl = 'https://doc.com/';
-      await element.updateComplete;
-      assert.equal(
-        element.computeHelpDocLink(),
-        'https://doc.com/user-search.html'
-      );
-    });
-
-    test('compute help doc url fallback to gerrit url', async () => {
-      element.docsBaseUrl = null;
-      await element.updateComplete;
-      assert.equal(
-        element.computeHelpDocLink(),
-        'https://gerrit-review.googlesource.com/Documentation/' +
-          'user-search.html'
-      );
-    });
-  });
 });
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 b97f54f..7e6e23b 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
@@ -24,7 +24,7 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
 import {css, html, LitElement, nothing} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators.js';
+import {customElement, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {subscribe} from '../../lit/subscription-controller';
 import {assert} from '../../../utils/common-util';
@@ -35,11 +35,13 @@
 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';
+import {anyLineTooLong} from '../../../utils/diff-util';
 import {fireReload} from '../../../utils/event-util';
 import {when} from 'lit/directives/when.js';
+import {Timing} from '../../../constants/reporting';
+import {changeModelToken} from '../../../models/change/change-model';
 
-interface FilePreview {
+export interface FilePreview {
   filepath: string;
   preview: DiffInfo;
 }
@@ -61,10 +63,10 @@
   @query('#nextFix')
   nextFix?: GrButton;
 
-  @property({type: Object})
+  @state()
   change?: ParsedChangeInfo;
 
-  @property({type: Number})
+  @state()
   changeNum?: NumericChangeId;
 
   @state()
@@ -101,8 +103,12 @@
 
   private readonly getUserModel = resolve(this, userModelToken);
 
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly reporting = getAppContext().reportingService;
+
   private readonly syntaxLayer = new GrSyntaxLayerWorker(
     resolve(this, highlightServiceToken),
     () => getAppContext().reportingService
@@ -130,32 +136,52 @@
         this.syntaxLayer.setEnabled(!!this.diffPrefs.syntax_highlighting);
       }
     );
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      change => (this.change = change)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      changeNum => (this.changeNum = changeNum)
+    );
   }
 
-  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);
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      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);
+        }
+        .info {
+          background-color: var(--info-background);
+          padding: var(--spacing-l) var(--spacing-xl);
+        }
+        .info gr-icon {
+          color: var(--selected-foreground);
+          margin-right: var(--spacing-xl);
+        }
+      `,
+    ];
+  }
 
   override render() {
     return html`
@@ -246,7 +272,9 @@
 
   private renderWarning(message: string) {
     if (!message) return nothing;
-    return html`<span><gr-icon icon="info"></gr-icon>${message}</span>`;
+    return html`<span class="info"
+      ><gr-icon icon="info"></gr-icon>${message}</span
+    >`;
   }
 
   /**
@@ -266,7 +294,9 @@
   private async showSelectedFixSuggestion(fixSuggestion: FixSuggestionInfo) {
     this.currentFix = fixSuggestion;
     this.loading = true;
+    this.reporting.time(Timing.PREVIEW_FIX_LOAD);
     await this.fetchFixPreview(fixSuggestion);
+    this.reporting.timeEnd(Timing.PREVIEW_FIX_LOAD);
     this.loading = false;
   }
 
@@ -376,6 +406,7 @@
       throw new Error('Not all required properties are set.');
     }
     this.isApplyFixLoading = true;
+    this.reporting.time(Timing.APPLY_FIX_LOAD);
     let res;
     if (this.fixSuggestions?.[0].fix_id === PROVIDED_FIX_ID) {
       res = await this.restApiService.applyFixSuggestion(
@@ -401,6 +432,7 @@
       this.close(true);
     }
     this.isApplyFixLoading = false;
+    this.reporting.timeEnd(Timing.APPLY_FIX_LOAD);
   }
 }
 
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 00e013b..04a55fd 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
@@ -204,11 +204,11 @@
 
   /**
    * Get the comments (with drafts and robot comments) for a path and
-   * patch-range. Returns an object with left and right properties mapping to
-   * arrays of comments in on either side of the patch range for that path.
+   * patch-range. Returns an array containing comments from either side of the
+   * patch range for that path.
    *
-   * @param patchRange The patch-range object containing patchNum
-   * and basePatchNum properties to represent the range.
+   * @param patchRange The patch-range object containing patchNum and
+   * basePatchNum properties to represent the range.
    */
   getCommentsForPath(path: string, patchRange: PatchRange): Comment[] {
     let comments: Comment[] = [];
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 6cc4048..00f7639 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
@@ -9,10 +9,9 @@
 import {
   anyLineTooLong,
   getDiffLength,
-  getLine,
-  getSide,
+  isImageDiff,
   SYNTAX_MAX_LINE_LENGTH,
-} from '../../../embed/diff/gr-diff/gr-diff-utils';
+} from '../../../utils/diff-util';
 import {getAppContext} from '../../../services/app-context';
 import {
   getParentIndex,
@@ -39,7 +38,6 @@
   PatchSetNum,
   RepoName,
   RevisionPatchSetNum,
-  UrlEncodedCommentId,
 } from '../../../types/common';
 import {
   DiffInfo,
@@ -47,14 +45,9 @@
   IgnoreWhitespaceType,
   WebLinkInfo,
 } from '../../../types/diff';
-import {
-  CreateCommentEventDetail,
-  GrDiff,
-} from '../../../embed/diff/gr-diff/gr-diff';
+import {GrDiff} from '../../../embed/diff/gr-diff/gr-diff';
 import {DiffViewMode, Side, CommentSide} from '../../../constants/constants';
 import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
-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 {
   firePageError,
@@ -64,21 +57,24 @@
   waitForEventOnce,
 } from '../../../utils/event-util';
 import {assertIsDefined} from '../../../utils/common-util';
-import {DiffContextExpandedEventDetail} from '../../../embed/diff/gr-diff-builder/gr-diff-builder';
 import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
 import {Timing} from '../../../constants/reporting';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
 import {Subscription} from 'rxjs';
 import {
+  CreateCommentEventDetail,
+  DiffContextExpandedExternalDetail,
   DisplayLine,
+  FILE,
+  LineNumber,
   LineSelectedEventDetail,
+  LOST,
   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';
@@ -99,6 +95,9 @@
 import {subscribe} from '../../lit/subscription-controller';
 import {userModelToken} from '../../../models/user/user-model';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {keyed} from 'lit/directives/keyed.js';
+import {repeat} from 'lit/directives/repeat.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
 
 const EMPTY_BLAME = 'No blame information for this diff.';
 
@@ -106,15 +105,6 @@
 const EVENT_ZERO_REBASE = 'rebase-percent-zero';
 const EVENT_NONZERO_REBASE = 'rebase-percent-nonzero';
 
-function isImageDiff(diff?: DiffInfo) {
-  if (!diff) return false;
-
-  const isA = diff.meta_a && diff.meta_a.content_type.startsWith('image/');
-  const isB = diff.meta_b && diff.meta_b.content_type.startsWith('image/');
-
-  return !!(diff.binary && (isA || isB));
-}
-
 // visible for testing
 export interface LineInfo {
   beforeNumber?: LineNumber;
@@ -125,7 +115,6 @@
   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>;
@@ -176,19 +165,6 @@
   projectName?: RepoName;
 
   @state()
-  private _isImageDiff = false;
-
-  get isImageDiff() {
-    return this._isImageDiff;
-  }
-
-  set isImageDiff(isImageDiff: boolean) {
-    if (this._isImageDiff === isImageDiff) return;
-    this._isImageDiff = isImageDiff;
-    fire(this, 'is-image-diff-changed', {value: isImageDiff});
-  }
-
-  @state()
   private _editWeblinks?: WebLinkInfo[];
 
   get editWeblinks() {
@@ -220,10 +196,12 @@
   @property({type: Boolean})
   noRenderOnPrefsChange = false;
 
-  // Private but used in tests.
   @state()
   threads: CommentThread[] = [];
 
+  @state()
+  checks: RunResult[] = [];
+
   @property({type: Boolean})
   lineWrapping = false;
 
@@ -263,7 +241,6 @@
     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);
   }
@@ -337,6 +314,12 @@
 
   private checksSubscription?: Subscription;
 
+  /**
+   * This key is used for the `keyed()` directive when rendering `gr-diff` and
+   * can thus be used to trigger re-construction of `gr-diff`.
+   */
+  private grDiffKey = 0;
+
   constructor() {
     super();
     this.syntaxLayer = new GrSyntaxLayerWorker(
@@ -385,6 +368,11 @@
   override connectedCallback() {
     super.connectedCallback();
     this.subscribeToChecks();
+    this.getPluginLoader().jsApiService.handleShowDiff({
+      change: this.change!,
+      fileRange: this.file!,
+      patchRange: this.patchRange!,
+    });
   }
 
   override disconnectedCallback() {
@@ -403,9 +391,6 @@
   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') ||
@@ -448,19 +433,6 @@
     }
   }
 
-  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.
-    // Change in layers will likely cause gr-diff to update. Since we add
-    // threads manually we need to call threadsChanged in this case as well.
-    if (changedProperties.has('threads') || changedProperties.has('layers')) {
-      this.threadsChanged(this.threads);
-    }
-  }
-
   async waitForReloadToRender(): Promise<void> {
     await this.updateComplete;
     if (this.reloadPromise) {
@@ -495,30 +467,43 @@
       KnownExperimentId.NEW_IMAGE_DIFF_UI
     );
 
-    return html` <gr-diff
-      id="diff"
-      ?hidden=${this.hidden}
-      .noAutoRender=${this.noAutoRender}
-      .path=${this.path}
-      .prefs=${this.prefs}
-      .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>`;
+    return keyed(
+      this.grDiffKey,
+      html`<gr-diff
+        id="diff"
+        ?hidden=${this.hidden}
+        .noAutoRender=${this.noAutoRender}
+        .path=${this.path}
+        .prefs=${this.prefs}
+        .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}
+      >
+        ${repeat(
+          this.threads,
+          t => t.rootId,
+          t => this.renderThread(t)
+        )}
+        ${repeat(
+          this.checks,
+          c => c.internalResultId,
+          c => this.renderCheck(c)
+        )}
+      </gr-diff>`
+    );
   }
 
   async initLayers() {
@@ -568,11 +553,10 @@
   async reloadInternal(shouldReportMetric?: boolean) {
     this.reporting.time(Timing.DIFF_TOTAL);
     this.reporting.time(Timing.DIFF_LOAD);
+    this.grDiffKey++;
     // 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;
@@ -669,7 +653,24 @@
   private getLayers(enableTokenHighlight: boolean): DiffLayer[] {
     const layers = [];
     if (enableTokenHighlight) {
-      layers.push(new TokenHighlightLayer(this));
+      layers.push(
+        new TokenHighlightLayer(this, highlight => {
+          for (const plugin of this.getPluginLoader().pluginsModel.getState()
+            .tokenHighlightPlugins) {
+            plugin.listener(
+              {
+                change: this.change!,
+                basePatchNum: this.patchRange!.basePatchNum,
+                patchNum: this.patchRange!.patchNum,
+                fileRange: this.file!,
+                path: this.path!,
+                diffElement: this.diffElement!,
+              },
+              highlight
+            );
+          }
+        })
+      );
     }
     layers.push(this.syntaxLayer);
     return layers;
@@ -689,7 +690,7 @@
     if (this.checksSubscription) {
       this.checksSubscription.unsubscribe();
       this.checksSubscription = undefined;
-      this.checksChanged([]);
+      this.checks = [];
     }
 
     const path = this.path;
@@ -708,76 +709,35 @@
         ),
         distinctUntilChanged(deepEqual)
       )
-      .subscribe(results => this.checksChanged(results));
+      .subscribe(results => (this.checks = 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>();
-    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);
-      } else {
-        const newEl = this.createCheckEl(check);
-        dontRemove.add(newEl);
-      }
-    }
-    // Remove all check els that don't have a matching check anymore.
-    for (const el of checkEls) {
-      if (dontRemove.has(el)) continue;
-      el.remove();
-    }
-  }
-
-  /**
-   * 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) {
+  private renderCheck(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}`);
+    let pointerAttr: string | undefined = undefined;
     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)}`);
+      pointerAttr = `${JSON.stringify(pointer.range)}`;
     }
-    assertIsDefined(this.diffElement);
-    this.diffElement.appendChild(el);
-    return el;
+    const line: LineNumber =
+      pointer.range?.end_line || pointer.range?.start_line || FILE;
+
+    return html`
+      <gr-diff-check-result
+        class="comment-thread"
+        .rootId=${check.internalResultId}
+        .result=${check}
+        slot=${`${Side.RIGHT}-${line}`}
+        diff-side=${Side.RIGHT}
+        line-num=${line}
+        range=${ifDefined(pointerAttr)}
+      ></gr-diff-check-result>
+    `;
   }
 
   private async getCoverageData() {
@@ -850,11 +810,6 @@
     };
   }
 
-  /** Cancel any remaining diff builder rendering work. */
-  cancel() {
-    this.diffElement?.cancel();
-  }
-
   getCursorStops() {
     assertIsDefined(this.diffElement);
     return this.diffElement.getCursorStops();
@@ -872,7 +827,10 @@
 
   toggleLeftDiff() {
     assertIsDefined(this.diffElement);
-    this.diffElement.toggleLeftDiff();
+    this.renderPrefs = {
+      ...this.renderPrefs,
+      hide_left_side: !this.renderPrefs.hide_left_side,
+    };
   }
 
   /**
@@ -899,26 +857,6 @@
     this.blame = null;
   }
 
-  getThreadEls(): GrCommentThread[] {
-    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) {
-    assertIsDefined(this.diffElement);
-    this.diffElement.addDraftAtLine(el);
-  }
-
-  clearDiffContent() {
-    this.diffElement?.clearDiffContent();
-  }
-
   toggleAllContext() {
     assertIsDefined(this.diffElement);
     this.diffElement.toggleAllContext();
@@ -1045,63 +983,6 @@
     }
   }
 
-  private threadsChanged(threads: CommentThread[]) {
-    const rootIdToThreadEl = new Map<UrlEncodedCommentId, GrCommentThread>();
-    const threadEls = this.getThreadEls();
-    for (const threadEl of threadEls) {
-      assertIsDefined(threadEl.rootId, 'threadEl.rootId');
-      rootIdToThreadEl.set(threadEl.rootId, threadEl);
-    }
-    const dontRemove = new Set<GrCommentThread>();
-    const threadCount = threads.length;
-    const threadElCount = threadEls.length;
-    if (threadCount === 0 && threadElCount === 0) return;
-
-    for (const thread of threads) {
-      // Let's find an existing DOM element matching the thread. Normally this
-      // is as simple as matching the rootIds.
-      const existingThreadEl =
-        thread.rootId && rootIdToThreadEl.get(thread.rootId);
-      // 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);
-      } else {
-        const threadEl = this.createThreadElement(thread);
-        this.attachThreadElement(threadEl);
-        dontRemove.add(threadEl);
-      }
-    }
-    // Remove all threads that are no longer existing.
-    for (const threadEl of this.getThreadEls()) {
-      if (dontRemove.has(threadEl)) continue;
-      threadEl.remove();
-    }
-    const portedThreadsCount = threads.filter(thread => thread.ported).length;
-    const portedThreadsWithoutRange = threads.filter(
-      thread => thread.ported && thread.rangeInfoLost
-    ).length;
-    if (portedThreadsCount > 0) {
-      this.reporting.reportInteraction('ported-threads-shown', {
-        ported: portedThreadsCount,
-        portedThreadsWithoutRange,
-      });
-    }
-  }
-
   private getImages(diff: DiffInfo) {
     assertIsDefined(this.changeNum, 'changeNum');
     assertIsDefined(this.patchRange, 'patchRange');
@@ -1178,11 +1059,6 @@
     return true;
   }
 
-  private attachThreadElement(threadEl: Element) {
-    assertIsDefined(this.diffElement);
-    this.diffElement.appendChild(threadEl);
-  }
-
   private getDiffSide(thread: CommentThread) {
     let diffSide: Side;
     assertIsDefined(this.patchRange, 'patchRange');
@@ -1203,62 +1079,23 @@
     return diffSide;
   }
 
-  private createThreadElement(thread: CommentThread) {
+  private renderThread(thread: CommentThread) {
     const diffSide = this.getDiffSide(thread);
-
-    const threadEl = document.createElement('gr-comment-thread');
-    threadEl.className = 'comment-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;
-  }
-
-  // Private but used in tests.
-  filterThreadElsForLocation(
-    threadEls: GrCommentThread[],
-    lineInfo: LineInfo,
-    side: Side
-  ) {
-    function matchesLeftLine(threadEl: GrCommentThread) {
-      return (
-        getSide(threadEl) === Side.LEFT &&
-        getLine(threadEl) === lineInfo.beforeNumber
-      );
-    }
-    function matchesRightLine(threadEl: GrCommentThread) {
-      return (
-        getSide(threadEl) === Side.RIGHT &&
-        getLine(threadEl) === lineInfo.afterNumber
-      );
-    }
-    function matchesFileComment(threadEl: GrCommentThread) {
-      return getSide(threadEl) === side && getLine(threadEl) === FILE;
-    }
-
-    // Select the appropriate matchers for the desired side and line
-    const matchers: ((thread: GrCommentThread) => boolean)[] = [];
-    if (side === Side.LEFT) {
-      matchers.push(matchesLeftLine);
-    }
-    if (side === Side.RIGHT) {
-      matchers.push(matchesRightLine);
-    }
-    if (lineInfo.afterNumber === FILE || lineInfo.beforeNumber === FILE) {
-      matchers.push(matchesFileComment);
-    }
-    return threadEls.filter(threadEl =>
-      matchers.some(matcher => matcher(threadEl))
-    );
+    const rangeAttr = thread.range ? JSON.stringify(thread.range) : undefined;
+    return html`
+      <gr-comment-thread
+        class="comment-thread"
+        .rootId=${thread.rootId}
+        .thread=${thread}
+        .showPatchset=${false}
+        .showPortedComment=${!!thread.ported}
+        slot=${`${diffSide}-${thread.line || LOST}`}
+        diff-side=${diffSide}
+        line-num=${thread.line || LOST}
+        range=${ifDefined(rangeAttr)}
+      >
+      </gr-comment-thread>
+    `;
   }
 
   private getIgnoreWhitespace(): IgnoreWhitespaceType {
@@ -1340,7 +1177,7 @@
   }
 
   private handleDiffContextExpanded(
-    e: CustomEvent<DiffContextExpandedEventDetail>
+    e: CustomEvent<DiffContextExpandedExternalDetail>
   ) {
     this.reporting.reportInteraction('diff-context-expanded', {
       numLines: e.detail.numLines,
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
index 43045f7..eea8aaa 100644
--- 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
@@ -33,6 +33,7 @@
   BasePatchSetNum,
   BlameInfo,
   CommentRange,
+  CommentThread,
   DraftInfo,
   EDIT,
   ImageInfo,
@@ -40,16 +41,13 @@
   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 {GrDiffHost} from './gr-diff-host';
 import {DiffInfo, DiffViewMode, IgnoreWhitespaceType} from '../../../api/diff';
 import {ErrorCallback} from '../../../api/rest';
 import {SinonStub, SinonStubbedMember} 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 {testResolver} from '../../../test/common-test-setup';
@@ -70,11 +68,13 @@
 
   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();
+    element = await fixture(html`<gr-diff-host
+      .changeNum=${123 as NumericChangeId}
+      .path=${'some/path'}
+      .file=${{path: 'some/path'}}
+      .change=${createChange()}
+      .patchRange=${createPatchRange()}
+    ></gr-diff-host>`);
     getDiffRestApiStub = stubRestApi('getDiff');
     // Fall back in case a test forgets to set one up
     getDiffRestApiStub.returns(Promise.resolve(createDiff()));
@@ -153,24 +153,6 @@
     );
   });
 
-  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;
@@ -315,25 +297,15 @@
       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 leftImage = queryAndAssert(element.diffElement, 'td.left img');
+      const leftLabel = queryAndAssert(element.diffElement, '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 rightImage = queryAndAssert(element.diffElement, 'td.right img');
+      const rightLabel = queryAndAssert(element.diffElement, 'td.right label');
       const rightLabelContent = rightLabel.querySelector('.label');
       const rightLabelName = rightLabel.querySelector('.name');
 
@@ -390,24 +362,16 @@
       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 leftImage = queryAndAssert(element.diffElement, 'td.left img');
+      const leftLabel = queryAndAssert(element.diffElement, '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 rightImage = queryAndAssert(element.diffElement, 'td.right img');
+      const rightLabel = queryAndAssert(element.diffElement, 'td.right label');
       const rightLabelContent = rightLabel.querySelector('.label');
       const rightLabelName = rightLabel.querySelector('.name');
 
@@ -461,18 +425,9 @@
       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');
+        const leftImage = query(element.diffElement, 'td.left img');
+        const rightImage = queryAndAssert(element.diffElement, 'td.right img');
 
         assert.isNotOk(leftImage);
         assert.isOk(rightImage);
@@ -509,19 +464,10 @@
       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');
+        const leftImage = queryAndAssert(element.diffElement, 'td.left img');
+        const rightImage = query(element.diffElement, 'td.right img');
 
         assert.isOk(leftImage);
         assert.isNotOk(rightImage);
@@ -563,17 +509,8 @@
 
       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');
+        const leftImage = query(element.diffElement, 'td.left img');
         assert.isNotOk(leftImage);
       });
     });
@@ -601,15 +538,6 @@
     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);
@@ -632,121 +560,73 @@
     assert.equal(stub.lastCall.args.length, 0);
   });
 
-  test('delegates toggleLeftDiff()', () => {
+  test('clearBlame', async () => {
+    element.blame = [];
+    await element.updateComplete;
     assertIsDefined(element.diffElement);
-    const stub = sinon.stub(element.diffElement, 'toggleLeftDiff');
-    element.toggleLeftDiff();
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 0);
+    const isBlameLoadedStub = sinon.stub();
+    element.addEventListener('is-blame-loaded-changed', isBlameLoadedStub);
+    element.clearBlame();
+    await element.updateComplete;
+    assert.isNull(element.blame);
+    assert.isTrue(isBlameLoadedStub.calledOnce);
+    assert.isFalse(isBlameLoadedStub.args[0][0].detail.value);
   });
 
-  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('loadBlame', async () => {
+    const mockBlame: BlameInfo[] = [createBlame()];
+    const showAlertStub = sinon.stub();
+    element.addEventListener('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);
 
-    test('clearBlame', async () => {
-      element.blame = [];
-      await element.updateComplete;
-      assertIsDefined(element.diffElement);
-      const setBlameSpy = sinon.spy(
-        element.diffElement.diffBuilder,
-        'setBlame'
+    return element.loadBlame().then(() => {
+      assert.isTrue(
+        getBlameStub.calledWithExactly(
+          changeNum,
+          1 as RevisionPatchSetNum,
+          'foo/bar.baz',
+          true
+        )
       );
-      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.isFalse(showAlertStub.called);
+      assert.equal(element.blame, mockBlame);
       assert.isTrue(isBlameLoadedStub.calledOnce);
-      assert.isFalse(isBlameLoadedStub.args[0][0].detail.value);
+      assert.isTrue(isBlameLoadedStub.args[0][0].detail.value);
     });
+  });
 
-    test('loadBlame', async () => {
-      const mockBlame: BlameInfo[] = [createBlame()];
-      const showAlertStub = sinon.stub();
-      element.addEventListener('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('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('loadBlame empty', async () => {
-      const mockBlame: BlameInfo[] = [];
-      const showAlertStub = sinon.stub();
-      const isBlameLoadedStub = sinon.stub();
-      element.addEventListener('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()', () => {
@@ -826,12 +706,9 @@
     let reportStub: SinonStubbedMember<ReportingService['reportInteraction']>;
 
     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 = sinon.stub(element.reporting, 'reportInteraction');
       reportStub.reset();
     });
 
@@ -931,36 +808,95 @@
     });
   });
 
-  suite('createCheckEl method', () => {
-    test('start_line:12', () => {
+  suite('render thread elements', () => {
+    test('right start_line:1', async () => {
+      const thread: CommentThread = {
+        ...createCommentThread([createComment()]),
+      };
+      element.threads = [thread];
+      await element.updateComplete;
+      assert.lightDom.equal(
+        element.diffElement,
+        /* HTML */ `
+          <gr-comment-thread
+            class="comment-thread"
+            diff-side="right"
+            line-num="1"
+            slot="right-1"
+          >
+          </gr-comment-thread>
+        `
+      );
+    });
+    test('left start_line:2', async () => {
+      const thread: CommentThread = {
+        ...createCommentThread([
+          createComment({side: CommentSide.PARENT, line: 2}),
+        ]),
+      };
+      element.threads = [thread];
+      await element.updateComplete;
+      assert.lightDom.equal(
+        element.diffElement,
+        /* HTML */ `
+          <gr-comment-thread
+            class="comment-thread"
+            diff-side="left"
+            line-num="2"
+            slot="left-2"
+          >
+          </gr-comment-thread>
+        `
+      );
+    });
+  });
+
+  suite('render check elements', () => {
+    test('start_line:12', async () => {
       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);
+      element.checks = [result];
+      await element.updateComplete;
+      assert.lightDom.equal(
+        element.diffElement,
+        /* HTML */ `
+          <gr-diff-check-result
+            class="comment-thread"
+            diff-side="right"
+            line-num="12"
+            slot="right-12"
+          >
+          </gr-diff-check-result>
+        `
+      );
     });
 
-    test('start_line:13 end_line:14 without char positions', () => {
+    test('start_line:13 end_line:14 without char positions', async () => {
       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);
+      element.checks = [result];
+      await element.updateComplete;
+      assert.lightDom.equal(
+        element.diffElement,
+        /* HTML */ `
+          <gr-diff-check-result
+            class="comment-thread"
+            diff-side="right"
+            line-num="14"
+            slot="right-14"
+          >
+          </gr-diff-check-result>
+        `
+      );
     });
 
-    test('start_line:13 end_line:14 with char positions', () => {
+    test('start_line:13 end_line:14 with char positions', async () => {
       const result: RunResult = {
         ...createRunResult(),
         codePointers: [
@@ -975,31 +911,42 @@
           },
         ],
       };
-      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}'
+      element.checks = [result];
+      await element.updateComplete;
+      assert.lightDom.equal(
+        element.diffElement,
+        /* HTML */ `
+          <gr-diff-check-result
+            class="comment-thread"
+            diff-side="right"
+            line-num="14"
+            slot="right-14"
+            range='{"start_line":13,"end_line":14,"start_character":5,"end_character":7}'
+          >
+          </gr-diff-check-result>
+        `
       );
-      assert.equal(el.result, result);
     });
 
-    test('empty range', () => {
+    test('empty range', async () => {
       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);
+      element.checks = [result];
+      await element.updateComplete;
+      assert.lightDom.equal(
+        element.diffElement,
+        /* HTML */ `
+          <gr-diff-check-result
+            class="comment-thread"
+            diff-side="right"
+            line-num="FILE"
+            slot="right-FILE"
+          >
+          </gr-diff-check-result>
+        `
+      );
     });
   });
 
@@ -1180,55 +1127,6 @@
       }
     );
 
-    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(
       'thread should use new file path if first created ' +
         'on patch set (left) but is base',
@@ -1299,71 +1197,6 @@
     });
   });
 
-  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 = {
@@ -1491,24 +1324,18 @@
     ];
 
     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 = {
+      element.prefs = {
         ...createDefaultDiffPrefs(),
         line_length: 10,
         show_tabs: true,
         tab_size: 4,
         context: -1,
       };
-      element.patchRange = createPatchRange();
-      element.prefs = prefs;
       await element.updateComplete;
 
+      coverageProviderStub = sinon
+        .stub()
+        .returns(Promise.resolve(exampleRanges));
       getDiffRestApiStub.returns(
         Promise.resolve({
           ...createDiff(),
diff --git a/polygerrit-ui/app/embed/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
similarity index 82%
rename from polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
rename to polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
index a9bdab8..f228fb3 100644
--- a/polygerrit-ui/app/embed/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
@@ -5,8 +5,9 @@
  */
 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 '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import {DiffViewMode} from '../../../constants/constants';
 import {customElement, property, state} from 'lit/decorators.js';
 import {fireIronAnnounce} from '../../../utils/event-util';
@@ -15,7 +16,7 @@
 import {css, html, LitElement} from 'lit';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {userModelToken} from '../../../models/user/user-model';
-import {ironAnnouncerRequestAvailability} from '../../../elements/polymer-util';
+import {ironAnnouncerRequestAvailability} from '../../polymer-util';
 
 @customElement('gr-diff-mode-selector')
 export class GrDiffModeSelector extends LitElement {
@@ -59,21 +60,23 @@
     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;
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      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`
@@ -87,9 +90,10 @@
           link
           class=${this.computeSideBySideSelected()}
           aria-pressed=${this.isSideBySideSelected()}
+          aria-label="Side-by-side diff"
           @click=${this.handleSideBySideTap}
         >
-          <gr-icon icon="view_column_2" filled></gr-icon>
+          <gr-icon icon="view_column_2" filled aria-hidden="true"></gr-icon>
         </gr-button>
       </gr-tooltip-content>
       <gr-tooltip-content
@@ -102,9 +106,10 @@
           link
           class=${this.computeUnifiedSelected()}
           aria-pressed=${this.isUnifiedSelected()}
+          aria-label="Unified diff"
           @click=${this.handleUnifiedTap}
         >
-          <gr-icon icon="calendar_view_day" filled></gr-icon>
+          <gr-icon icon="calendar_view_day" filled aria-hidden="true"></gr-icon>
         </gr-button>
       </gr-tooltip-content>
     `;
diff --git a/polygerrit-ui/app/embed/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
similarity index 87%
rename from polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
rename to polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
index d646988..2d51eed 100644
--- a/polygerrit-ui/app/embed/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
@@ -16,7 +16,7 @@
 } 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 {GrButton} from '../../shared/gr-button/gr-button';
 import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-diff-mode-selector tests', () => {
@@ -58,10 +58,11 @@
             class="selected"
             aria-disabled="false"
             aria-pressed="true"
+            aria-label="Side-by-side diff"
             role="button"
             tabindex="0"
           >
-            <gr-icon icon="view_column_2" filled></gr-icon>
+            <gr-icon icon="view_column_2" filled aria-hidden="true"></gr-icon>
           </gr-button>
         </gr-tooltip-content>
         <gr-tooltip-content has-tooltip title="Unified diff">
@@ -71,9 +72,14 @@
             role="button"
             aria-disabled="false"
             aria-pressed="false"
+            aria-label="Unified diff"
             tabindex="0"
           >
-            <gr-icon filled icon="calendar_view_day"></gr-icon>
+            <gr-icon
+              filled
+              icon="calendar_view_day"
+              aria-hidden="true"
+            ></gr-icon>
           </gr-button>
         </gr-tooltip-content>
       `
@@ -100,10 +106,11 @@
             class=""
             aria-disabled="false"
             aria-pressed="false"
+            aria-label="Side-by-side diff"
             role="button"
             tabindex="0"
           >
-            <gr-icon icon="view_column_2" filled></gr-icon>
+            <gr-icon icon="view_column_2" filled aria-hidden="true"></gr-icon>
           </gr-button>
         </gr-tooltip-content>
         <gr-tooltip-content has-tooltip title="Unified diff">
@@ -114,9 +121,14 @@
             role="button"
             aria-disabled="false"
             aria-pressed="true"
+            aria-label="Unified diff"
             tabindex="0"
           >
-            <gr-icon icon="calendar_view_day" filled></gr-icon>
+            <gr-icon
+              icon="calendar_view_day"
+              filled
+              aria-hidden="true"
+            ></gr-icon>
           </gr-button>
         </gr-tooltip-content>
       `
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 a5593d6..ddb6591 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
@@ -5,6 +5,8 @@
  */
 import '@polymer/iron-dropdown/iron-dropdown';
 import '@polymer/iron-input/iron-input';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../../styles/gr-a11y-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
@@ -14,10 +16,9 @@
 import '../../shared/gr-select/gr-select';
 import '../../shared/gr-weblink/gr-weblink';
 import '../../shared/revision-info/revision-info';
-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 '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
+import '../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 '../../change/gr-download-dialog/gr-download-dialog';
@@ -46,11 +47,11 @@
   PreferencesInfo,
   RepoName,
   RevisionPatchSetNum,
-  ServerInfo,
+  Comment,
   CommentMap,
 } from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo, WebLinkInfo} from '../../../types/diff';
-import {FileRange, ParsedChangeInfo} from '../../../types/types';
+import {ParsedChangeInfo} from '../../../types/types';
 import {
   FilesWebLinks,
   PatchRangeChangeEvent,
@@ -61,17 +62,21 @@
 import {OpenFixPreviewEvent, ValueChangedEvent} from '../../../types/events';
 import {fireAlert, fire} from '../../../utils/event-util';
 import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
-import {toggleClass, whenVisible} from '../../../utils/dom-util';
+import {whenVisible} from '../../../utils/dom-util';
 import {CursorMoveResult} from '../../../api/core';
 import {throttleWrap} from '../../../utils/async-util';
-import {filter, take, switchMap} from 'rxjs/operators';
+import {filter, take, switchMap, map} from 'rxjs/operators';
 import {combineLatest} from 'rxjs';
 import {
   Shortcut,
   ShortcutSection,
   shortcutsServiceToken,
 } from '../../../services/shortcuts/shortcuts-service';
-import {DisplayLine, LineSelectedEventDetail} from '../../../api/diff';
+import {
+  DisplayLine,
+  FileRange,
+  LineSelectedEventDetail,
+} 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';
@@ -80,11 +85,11 @@
 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 {styleMap} from 'lit/directives/style-map.js';
 import {
   createDiffUrl,
   ChangeChildView,
@@ -98,6 +103,8 @@
   FileNameToNormalizedFileInfoMap,
   filesModelToken,
 } from '../../../models/change/files-model';
+import {isImageDiff} from '../../../utils/diff-util';
+import {formStyles} from '../../../styles/form-styles';
 
 const LOADING_BLAME = 'Loading blame...';
 const LOADED_BLAME = 'Blame loaded';
@@ -140,6 +147,11 @@
   @query('#diffPreferencesDialog')
   diffPreferencesDialog?: GrDiffPreferencesDialog;
 
+  @query('.sidebarAnchor')
+  sidebarAnchor?: HTMLDivElement;
+
+  @state() private sidebarHeight = 0;
+
   // Private but used in tests.
   @state()
   get patchRange(): PatchRange | undefined {
@@ -183,6 +195,8 @@
 
   @state() path?: string;
 
+  @state() private shownSidebar?: string;
+
   /** Allows us to react when the user switches to the DIFF view. */
   // Private but used in tests.
   @state() isActiveChildView = false;
@@ -194,17 +208,11 @@
   @property({type: Object})
   prefs?: DiffPreferencesInfo;
 
-  @state()
-  private serverConfig?: ServerInfo;
-
   // Private but used in tests.
   @state()
   userPrefs?: PreferencesInfo;
 
   @state()
-  private isImageDiff?: boolean;
-
-  @state()
   private editWeblinks?: WebLinkInfo[];
 
   @state()
@@ -226,6 +234,9 @@
   @state()
   leftSide = false;
 
+  @state()
+  commentsForPath: Comment[] = [];
+
   // visible for testing
   reviewedFiles = new Set<string>();
 
@@ -241,8 +252,6 @@
 
   private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
 
-  private readonly getConfigModel = resolve(this, configModelToken);
-
   private readonly getViewModel = resolve(this, changeViewModelToken);
 
   private throttledToggleFileReviewed?: (e: KeyboardEvent) => void;
@@ -340,13 +349,6 @@
     );
     subscribe(
       this,
-      () => this.getConfigModel().serverConfig$,
-      config => {
-        this.serverConfig = config;
-      }
-    );
-    subscribe(
-      this,
       () => this.getCommentsModel().changeComments$,
       changeComments => {
         this.changeComments = changeComments;
@@ -480,6 +482,7 @@
 
   static override get styles() {
     return [
+      formStyles,
       a11yStyles,
       sharedStyles,
       modalStyles,
@@ -487,6 +490,7 @@
         :host {
           display: block;
           background-color: var(--view-background-color);
+          --sidebar-width: 300px;
         }
         .hidden {
           display: none;
@@ -501,9 +505,8 @@
           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;
+          /* sidebar should outrank <footer> in GrAppElement */
+          z-index: 110;
           box-shadow: var(--elevation-level-1);
           /* This is just for giving the box-shadow some space. */
           margin-bottom: 2px;
@@ -662,6 +665,25 @@
         :host(.hideComments) {
           --gr-comment-thread-display: none;
         }
+        .diffContainer.sidebarOpen {
+          margin-left: var(--sidebar-width);
+        }
+        .sidebarTriggerContainer {
+          display: inline-block;
+          margin-right: var(--spacing-m);
+        }
+        .sidebarAnchor {
+          height: 0;
+          width: 0;
+          overflow: visible;
+        }
+        .sidebarContents {
+          background: var(--background-color-secondary);
+          width: var(--sidebar-width);
+          border: var(--spacing-xxs) solid var(--border-color);
+          border-left: 0;
+          overflow: auto;
+        }
       `,
     ];
   }
@@ -674,10 +696,27 @@
     this.addEventListener('open-fix-preview', e => this.onOpenFixPreview(e));
     this.cursor = new GrDiffCursor();
     if (this.diffHost) this.reInitCursor();
+    window.addEventListener('scroll', this.updateSidebarHeight);
+    window.addEventListener('resize', this.updateSidebarHeight);
+    this.getUserModel()
+      .preferences$.pipe(
+        map(p => p.diff_page_sidebar),
+        take(1)
+      )
+      .toPromise()
+      .then(initialSidebar => {
+        if (initialSidebar === 'NONE' || initialSidebar === undefined) {
+          this.shownSidebar = undefined;
+        } else {
+          this.shownSidebar = initialSidebar.substring('plugin-'.length);
+        }
+      });
   }
 
   override disconnectedCallback() {
     this.cursor?.dispose();
+    window.removeEventListener('scroll', this.updateSidebarHeight);
+    window.removeEventListener('resize', this.updateSidebarHeight);
     super.disconnectedCallback();
   }
 
@@ -687,6 +726,13 @@
     this.cursor?.reInitCursor();
   }
 
+  private readonly updateSidebarHeight = () => {
+    if (this.sidebarAnchor) {
+      this.sidebarHeight =
+        window.innerHeight - this.sidebarAnchor.getBoundingClientRect().bottom;
+    }
+  };
+
   protected override updated(changedProperties: PropertyValues): void {
     super.updated(changedProperties);
     if (
@@ -731,6 +777,21 @@
         });
       }
     }
+    if (
+      (changedProperties.has('change') ||
+        changedProperties.has('changeComments') ||
+        changedProperties.has('path') ||
+        changedProperties.has('patchRange')) &&
+      this.changeComments !== undefined &&
+      this.path !== undefined &&
+      this.patchRange !== undefined
+    ) {
+      this.commentsForPath = this.changeComments.getCommentsForPath(
+        this.path,
+        this.patchRange
+      );
+    }
+    this.updateSidebarHeight();
   }
 
   override render() {
@@ -742,25 +803,26 @@
     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.onCommentAnchorTap}
-        @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>
+      <div class="diffContainer ${this.shownSidebar && 'sidebarOpen'}">
+        <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.onCommentAnchorTap}
+          @line-selected=${this.onLineSelected}
+          @diff-changed=${this.onDiffChanged}
+          @edit-weblinks-changed=${this.onEditWeblinksChanged}
+          @files-weblinks-changed=${this.onFilesWeblinksChanged}
+          @render=${this.reInitCursor}
+        >
+        </gr-diff-host>
+      </div>
       ${this.renderDialogs()}
     `;
   }
@@ -785,6 +847,7 @@
           >&gt;</a
         >
       </div>
+      ${this.renderSidebarContent()}
     </div>`;
   }
 
@@ -854,6 +917,106 @@
       </div>`;
   }
 
+  private renderSidebarTriggers() {
+    return html`
+      <div class="sidebarTriggerContainer">
+        <gr-endpoint-decorator name="sidebarTrigger">
+          <gr-endpoint-param
+            name="onTrigger"
+            .value=${(pluginName: string) => {
+              this.shownSidebar =
+                this.shownSidebar === pluginName ? undefined : pluginName;
+              this.getUserModel().updatePreferences({
+                diff_page_sidebar:
+                  this.shownSidebar === pluginName
+                    ? 'NONE'
+                    : `plugin-${pluginName}`,
+              });
+            }}
+          ></gr-endpoint-param>
+          <!-- params cannot start falsy, so the value must be wrapped -->
+          <gr-endpoint-param
+            name="openSidebar"
+            .value=${{name: this.shownSidebar}}
+          ></gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </div>
+    `;
+  }
+
+  private renderSidebarContent() {
+    // Always renders the 0x0px .sidebarAnchor div for scroll measurements.
+    return html`
+      <div class="sidebarAnchor">
+        ${when(
+          this.shownSidebar !== undefined,
+          () => html`
+            <div
+              class="sidebarContents"
+              style=${styleMap({height: `${this.sidebarHeight}px`})}
+            >
+              <gr-endpoint-decorator
+                name=${`sidebarContent-${this.shownSidebar}`}
+              >
+                <gr-endpoint-param
+                  name="change"
+                  .value=${this.change}
+                ></gr-endpoint-param>
+                <gr-endpoint-param
+                  name="path"
+                  .value=${this.path}
+                ></gr-endpoint-param>
+                <!-- current diff path and, in case of rename, previous path -->
+                <gr-endpoint-param
+                  name="fileRange"
+                  .value=${this.getFileRange()}
+                ></gr-endpoint-param>
+                <gr-endpoint-param
+                  name="basePatchNum"
+                  .value=${this.basePatchNum}
+                ></gr-endpoint-param>
+                <gr-endpoint-param
+                  name="patchNum"
+                  .value=${this.patchNum}
+                ></gr-endpoint-param>
+                <gr-endpoint-param
+                  name="content"
+                  .value=${this.diff}
+                ></gr-endpoint-param>
+                <gr-endpoint-param
+                  name="cursor"
+                  .value=${this.cursor}
+                ></gr-endpoint-param>
+                <gr-endpoint-param
+                  name="diff"
+                  .value=${this.diffHost?.diffElement}
+                ></gr-endpoint-param>
+                <gr-endpoint-param
+                  name="comments"
+                  .value=${this.commentsForPath}
+                ></gr-endpoint-param>
+                <gr-endpoint-param
+                  name="onClose"
+                  .value=${(pluginName: string) => {
+                    // Only close the sidebar if that particular sidebar is
+                    // still open. An async onClose callback should not close a
+                    // different sidebar.
+                    if (this.shownSidebar !== pluginName) return;
+                    this.shownSidebar = undefined;
+                    this.getUserModel().updatePreferences({
+                      diff_page_sidebar: 'NONE',
+                    });
+                  }}
+                >
+                </gr-endpoint-param>
+              </gr-endpoint-decorator>
+            </div>
+          `
+        )}
+      </div>
+    `;
+  }
+
   private renderPatchRangeLeft() {
     return html` <div class="patchRangeLeft">
       <gr-patch-range-select
@@ -878,11 +1041,12 @@
 
   private renderRightControls() {
     const blameLoaderClass =
-      !isMagicPath(this.path) && !this.isImageDiff ? 'show' : '';
+      !isMagicPath(this.path) && !isImageDiff(this.diff) ? 'show' : '';
     const blameToggleLabel =
       this.isBlameLoaded && !this.isBlameLoading ? 'Hide blame' : 'Show blame';
     const diffModeSelectorClass = !this.diff || this.diff.binary ? 'hide' : '';
     return html` <div class="rightControls">
+      ${this.renderSidebarTriggers()}
       <span class="blameLoader ${blameLoaderClass}">
         <gr-button
           link=""
@@ -966,23 +1130,17 @@
   }
 
   private renderDialogs() {
-    return html` <gr-apply-fix-dialog
-        id="applyFixDialog"
-        .change=${this.change}
-        .changeNum=${this.changeNum}
-      >
-      </gr-apply-fix-dialog>
+    return html`
+      <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"
-          .change=${this.change}
-          .patchNum=${this.patchNum}
-          .config=${this.serverConfig?.download}
           @close=${this.handleDownloadDialogClose}
         ></gr-download-dialog>
-      </dialog>`;
+      </dialog>
+    `;
   }
 
   /**
@@ -999,6 +1157,10 @@
     }
   }
 
+  /**
+   * Returns the current file path and, if it was renamed in this change, the
+   * previous file path.
+   */
   private getFileRange() {
     if (!this.files || !this.path) return;
     const fileInfo = this.files.changeFilesByPath[this.path];
@@ -1068,10 +1230,6 @@
     this.filesWeblinks = e.detail.value;
   }
 
-  private onIsImageDiffChanged(e: ValueChangedEvent<boolean>) {
-    this.isImageDiff = e.detail.value;
-  }
-
   private handleNextLine() {
     assertIsDefined(this.diffHost, 'diffHost');
     this.cursor?.moveDown();
@@ -1273,12 +1431,9 @@
   private goToEditFile() {
     assertIsDefined(this.path, 'path');
 
-    // TODO(taoalpha): add a shortcut for editing
-    const cursorAddress = this.cursor?.getAddress();
-    this.getChangeModel().navigateToEdit({
-      path: this.path,
-      lineNum: cursorAddress?.number,
-    });
+    const lineNumber = this.cursor?.getTargetLineNumber();
+    const lineNum = typeof lineNumber === 'number' ? lineNumber : undefined;
+    this.getChangeModel().navigateToEdit({path: this.path, lineNum});
   }
 
   /**
@@ -1593,7 +1748,7 @@
   }
 
   private handleToggleHideAllCommentThreads() {
-    toggleClass(this, 'hideComments');
+    this.classList.toggle('hideComments');
   }
 
   private handleOpenFileList() {
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
index 737e964..93424b1 100644
--- 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
@@ -54,10 +54,9 @@
 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 {GrDiffModeSelector} from '../gr-diff-mode-selector/gr-diff-mode-selector';
 import {fixture, html, assert} from '@open-wc/testing';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {testResolver} from '../../../test/common-test-setup';
@@ -77,6 +76,7 @@
 import {FileNameToNormalizedFileInfoMap} from '../../../models/change/files-model';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
+import {LoadingStatus} from '../../../types/types';
 
 function createComment(
   id: string,
@@ -270,6 +270,12 @@
                 </span>
               </div>
               <div class="rightControls">
+                <div class="sidebarTriggerContainer">
+                  <gr-endpoint-decorator name="sidebarTrigger">
+                    <gr-endpoint-param name="onTrigger"></gr-endpoint-param>
+                    <gr-endpoint-param name="openSidebar"></gr-endpoint-param>
+                  </gr-endpoint-decorator>
+                </div>
                 <span class="blameLoader show">
                   <gr-button
                     aria-disabled="false"
@@ -348,9 +354,12 @@
                 >
               </a>
             </div>
+            <div class="sidebarAnchor"></div>
           </div>
           <h2 class="assistive-tech-only">Diff view</h2>
-          <gr-diff-host id="diffHost"> </gr-diff-host>
+          <div class="diffContainer">
+            <gr-diff-host id="diffHost"> </gr-diff-host>
+          </div>
           <gr-apply-fix-dialog id="applyFixDialog"> </gr-apply-fix-dialog>
           <gr-diff-preferences-dialog id="diffPreferencesDialog">
           </gr-diff-preferences-dialog>
@@ -806,9 +815,7 @@
       element.patchNum = 1 as RevisionPatchSetNum;
       element.basePatchNum = PARENT;
       assertIsDefined(element.cursor);
-      sinon
-        .stub(element.cursor, 'getAddress')
-        .returns({number: lineNumber, leftSide: false});
+      sinon.stub(element.cursor, 'getTargetLineNumber').returns(lineNumber);
       await element.updateComplete;
       const editBtn = queryAndAssert<GrButton>(
         element,
@@ -818,7 +825,7 @@
       editBtn.click();
       assert.equal(navToEditStub.callCount, 1);
       assert.deepEqual(navToEditStub.lastCall.args, [
-        {path: 't.txt', lineNum: 42},
+        {path: 't.txt', lineNum: lineNumber},
       ]);
     });
 
@@ -1428,9 +1435,6 @@
     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 = {
@@ -1450,9 +1454,6 @@
     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 = {
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 76d67af..6bf1adc 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
@@ -16,6 +16,9 @@
   isMergeParent,
   PatchSet,
   convertToPatchSetNum,
+  getParentInfoString,
+  shorten,
+  getParentCommit,
 } from '../../../utils/patch-set-util';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {
@@ -47,14 +50,11 @@
 import {changeModelToken} from '../../../models/change/change-model';
 import {changeViewModelToken} from '../../../models/views/change';
 import {fireNoBubbleNoCompose} from '../../../utils/event-util';
+import {FlagsService, KnownExperimentId} from '../../../services/flags/flags';
 
 // Maximum length for patch set descriptions.
 const PATCH_DESC_MAX_LENGTH = 500;
 
-function getShaForPatch(patch: PatchSet) {
-  return patch.sha.substring(0, 10);
-}
-
 export interface PatchRangeChangeDetail {
   patchNum?: RevisionPatchSetNum;
   basePatchNum?: BasePatchSetNum;
@@ -119,6 +119,8 @@
   private readonly reporting: ReportingService =
     getAppContext().reportingService;
 
+  private readonly flags: FlagsService = getAppContext().flagsService;
+
   private readonly getCommentsModel = resolve(this, commentsModelToken);
 
   private readonly getChangeModel = resolve(this, changeModelToken);
@@ -255,6 +257,7 @@
     const maxParents = this.revisionInfo.getMaxParents();
     const isMerge = this.revisionInfo.isMergeCommit(this.patchNum);
     const parentCount = this.revisionInfo.getParentCount(this.patchNum);
+    const rev = getRevisionByPatchNum(this.sortedRevisions, this.patchNum);
 
     const dropdownContent: DropdownItem[] = [];
     for (const basePatch of this.availablePatches) {
@@ -262,7 +265,7 @@
       const entry: DropdownItem = this.createDropdownEntry(
         basePatchNum,
         'Patchset ',
-        getShaForPatch(basePatch)
+        shorten(basePatch.sha)!
       );
       dropdownContent.push({
         ...entry,
@@ -270,8 +273,14 @@
       });
     }
 
+    const showParentsData = this.flags.isEnabled(
+      KnownExperimentId.REVISION_PARENTS_DATA
+    );
     dropdownContent.push({
-      text: isMerge ? 'Auto Merge' : 'Base',
+      triggerText: isMerge ? 'Auto Merge' : 'Base',
+      text: isMerge ? 'Auto Merge' : `Base | ${getParentCommit(rev, 0)}`,
+      bottomText:
+        showParentsData && !isMerge ? getParentInfoString(rev, 0) : undefined,
       value: PARENT,
     });
 
@@ -279,7 +288,8 @@
       dropdownContent.push({
         disabled: idx >= parentCount,
         triggerText: `Parent ${idx + 1}`,
-        text: `Parent ${idx + 1}`,
+        text: `Parent ${idx + 1} | ${getParentCommit(rev, idx)}`,
+        bottomText: showParentsData ? getParentInfoString(rev, idx) : undefined,
         mobileText: `Parent ${idx + 1}`,
         value: -(idx + 1),
       });
@@ -312,7 +322,7 @@
       const entry = this.createDropdownEntry(
         patchNum,
         patchNum === EDIT ? '' : 'Patchset ',
-        getShaForPatch(patch)
+        shorten(patch.sha)!
       );
       dropdownContent.push({
         ...entry,
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 b4ab043..e7ed97b 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
@@ -180,8 +180,10 @@
         commentThreads: [],
       } as DropdownItem,
       {
-        text: 'Base',
+        text: 'Base | ',
+        triggerText: 'Base',
         value: PARENT,
+        bottomText: undefined,
       } as DropdownItem,
     ];
     element.patchNum = 1 as PatchSetNumber;
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 5786112..1e871ce 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
@@ -35,6 +35,7 @@
 import {whenVisible} from '../../../utils/dom-util';
 import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
 import {changeModelToken} from '../../../models/change/change-model';
+import {formStyles} from '../../../styles/form-styles';
 
 @customElement('gr-edit-controls')
 export class GrEditControls extends LitElement {
@@ -84,6 +85,7 @@
 
   static override get styles() {
     return [
+      formStyles,
       sharedStyles,
       modalStyles,
       css`
@@ -563,8 +565,6 @@
       }
 
       const fr = new FileReader();
-      // TODO(TS): Do we need this line?
-      // fr.file = file;
       fr.onload = (fileLoadEvent: ProgressEvent<FileReader>) => {
         if (!fileLoadEvent) return;
         const fileData = fileLoadEvent.target!.result;
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 f37a3d9..dc3bf7b 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
@@ -479,6 +479,8 @@
 
       this.showAlert(PUBLISHING_EDIT_MSG);
 
+      // restApiService has some quirks where it will still call .then() with
+      // undefined or Response status 429 when it hits an error.
       this.restApiService
         .executeChangeAction(
           changeNum,
@@ -488,7 +490,14 @@
           {notify: NotifyType.NONE},
           handleError
         )
-        .then(() => {
+        .then(res => {
+          if (
+            res === undefined ||
+            (res instanceof Response && res.status === 429)
+          ) {
+            // In an error case we should not navigate and lose edits.
+            return;
+          }
           assertIsDefined(this.change, 'change');
           this.getChangeModel().navigateToChangeResetReload();
         });
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index f81640d..6dfa972 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -21,14 +21,12 @@
 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-plugin-host/gr-plugin-host';
+import './plugins/gr-plugin-screen/gr-plugin-screen';
 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 './core/gr-notifications-prompt/gr-notifications-prompt';
-import {loginUrl} from '../utils/url-util';
 import {navigationToken} from './core/gr-navigation/gr-navigation';
 import {getAppContext} from '../services/app-context';
 import {routerToken} from './core/gr-router/gr-router';
@@ -46,7 +44,6 @@
 import {GrMainHeader} from './core/gr-main-header/gr-main-header';
 import {GrSettingsView} from './settings/gr-settings-view/gr-settings-view';
 import {
-  DialogChangeEventDetail,
   PageErrorEventDetail,
   RpcLogEvent,
   TitleChangeEventDetail,
@@ -67,15 +64,17 @@
 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} from '../models/views/search';
 import {createSettingsUrl} from '../models/views/settings';
-import {createDashboardUrl} from '../models/views/dashboard';
+import {DashboardType, 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';
-import {configModelToken} from '../models/config/config-model';
+import {
+  ALLOW_LISTED_FULL_SCREEN_PLUGINS,
+  pluginViewModelToken,
+} from '../models/views/plugin';
 
 interface ErrorInfo {
   text: string;
@@ -83,12 +82,6 @@
   moreInfo?: string;
 }
 
-/**
- * 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 LitElement {
@@ -119,7 +112,7 @@
 
   @state() private version?: string;
 
-  @state() private view?: GerritView;
+  @state() view?: GerritView;
 
   // TODO: Introduce a wrapper element for CHANGE, DIFF, EDIT view.
   @state() private childView?: ChangeChildView;
@@ -140,20 +133,11 @@
 
   @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
-  @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
-  @state() private mainAriaHidden = false;
-
   @state() private theme = AppTheme.AUTO;
 
-  @state()
-  serverConfig?: ServerInfo;
+  @state() private pluginScreenName = '';
+
+  @state() serverConfig?: ServerInfo;
 
   readonly getRouter = resolve(this, routerToken);
 
@@ -173,7 +157,7 @@
 
   private readonly getChangeViewModel = resolve(this, changeViewModelToken);
 
-  private readonly getConfigModel = resolve(this, configModelToken);
+  private readonly getPluginViewModel = resolve(this, pluginViewModelToken);
 
   constructor() {
     super();
@@ -184,16 +168,15 @@
     document.addEventListener('title-change', e => {
       this.handleTitleChange(e);
     });
-    this.addEventListener('dialog-change', e => {
-      this.handleDialogChange(e as CustomEvent<DialogChangeEventDetail>);
-    });
     document.addEventListener('location-change', () => this.requestUpdate());
     document.addEventListener('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.getNavigation().setUrl(
+        createDashboardUrl({type: DashboardType.USER, user: 'self'})
+      )
     );
     this.shortcuts.addAbstract(Shortcut.GO_TO_OPENED_CHANGES, () =>
       this.getNavigation().setUrl(createSearchUrl({statuses: ['open']}))
@@ -222,14 +205,6 @@
 
     subscribe(
       this,
-      () => this.getConfigModel().serverConfig$,
-      config => {
-        this.serverConfig = config;
-      }
-    );
-
-    subscribe(
-      this,
       () => this.getUserModel().preferenceTheme$,
       theme => {
         this.theme = theme;
@@ -246,6 +221,11 @@
     );
     subscribe(
       this,
+      () => this.getPluginViewModel().screenName$,
+      screenName => (this.pluginScreenName = screenName)
+    );
+    subscribe(
+      this,
       () => this.getChangeViewModel().childView$,
       childView => (this.childView = childView)
     );
@@ -317,11 +297,11 @@
           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
+          /* Make sure the header is above the main content, to preserve
+            box-shadow visibility. We need 111 here 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;
+            z-index of 110. */
+          z-index: 111;
         }
         footer {
           background: var(
@@ -378,7 +358,7 @@
       <gr-css-mixins></gr-css-mixins>
       <gr-endpoint-decorator name="banner"></gr-endpoint-decorator>
       ${this.renderHeader()}
-      <main ?aria-hidden=${this.mainAriaHidden}>
+      <main>
         ${this.renderMobileSearch()} ${this.renderChangeListView()}
         ${this.renderDashboardView()}
         ${
@@ -406,11 +386,7 @@
       ${this.renderRegistrationDialog()}
       <gr-notifications-prompt></gr-notifications-prompt>
       <gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator>
-      <gr-error-manager
-        id="errorManager"
-        .loginUrl=${loginUrl(this.serverConfig?.auth)}
-        .loginText=${this.serverConfig?.auth.login_text ?? 'Sign in'}
-      ></gr-error-manager>
+      <gr-error-manager id="errorManager"></gr-error-manager>
       <gr-plugin-host id="plugins"></gr-plugin-host>
     `;
   }
@@ -423,9 +399,6 @@
         @mobile-search=${this.mobileSearchToggle}
         @show-keyboard-shortcuts=${this.showKeyboardShortcuts}
         .mobileSearchHidden=${!this.mobileSearch}
-        .loginUrl=${loginUrl(this.serverConfig?.auth)}
-        .loginText=${this.serverConfig?.auth.login_text ?? 'Sign in'}
-        ?aria-hidden=${this.footerHeaderAriaHidden}
       >
       </gr-main-header>
     `;
@@ -434,12 +407,12 @@
   private renderFooter() {
     if (this.hideHeaderAndFooter()) return nothing;
     return html`
-      <footer ?aria-hidden=${this.footerHeaderAriaHidden}>
+      <footer>
         <div>
           Powered by
           <a
             href="https://www.gerritcodereview.com/"
-            rel="noopener"
+            rel="noopener noreferrer"
             target="_blank"
             >Gerrit Code Review</a
           >
@@ -457,7 +430,7 @@
   private hideHeaderAndFooter() {
     return (
       this.view === GerritView.PLUGIN_SCREEN &&
-      WHITE_LISTED_FULL_SCREEN_PLUGINS.includes(this.computePluginScreenName())
+      ALLOW_LISTED_FULL_SCREEN_PLUGINS.includes(this.pluginScreenName)
     );
   }
 
@@ -552,19 +525,7 @@
 
   private renderPluginScreen() {
     if (this.view !== GerritView.PLUGIN_SCREEN) return nothing;
-    const pluginViewState = this.params as PluginViewState;
-    const pluginScreenName = this.computePluginScreenName();
-    return keyed(
-      pluginScreenName,
-      html`
-        <gr-endpoint-decorator .name=${pluginScreenName}>
-          <gr-endpoint-param
-            name="token"
-            .value=${pluginViewState.screen}
-          ></gr-endpoint-param>
-        </gr-endpoint-decorator>
-      `
-    );
+    return html`<gr-plugin-screen></gr-plugin-screen>`;
   }
 
   private renderCLAView() {
@@ -580,11 +541,7 @@
   private renderKeyboardShortcutsDialog() {
     if (!this.loadKeyboardShortcutsDialog) return nothing;
     return html`
-      <dialog
-        id="keyboardShortcuts"
-        tabindex="-1"
-        @close=${this.onModalCanceled}
-      >
+      <dialog id="keyboardShortcuts" tabindex="-1">
         <gr-keyboard-shortcuts-dialog
           @close=${this.handleKeyboardShortcutDialogClose}
         ></gr-keyboard-shortcuts-dialog>
@@ -707,14 +664,6 @@
     }
   }
 
-  private handleDialogChange(e: CustomEvent<DialogChangeEventDetail>) {
-    if (e.detail.canceled) {
-      this.footerHeaderAriaHidden = false;
-    } else if (e.detail.opened) {
-      this.footerHeaderAriaHidden = true;
-    }
-  }
-
   private async showKeyboardShortcuts() {
     this.loadKeyboardShortcutsDialog = true;
     await this.updateComplete;
@@ -724,8 +673,6 @@
       this.keyboardShortcuts.close();
       return;
     }
-    this.footerHeaderAriaHidden = true;
-    this.mainAriaHidden = true;
     this.keyboardShortcuts.showModal();
   }
 
@@ -734,11 +681,6 @@
     this.keyboardShortcuts.close();
   }
 
-  onModalCanceled() {
-    this.footerHeaderAriaHidden = false;
-    this.mainAriaHidden = false;
-  }
-
   private handleAccountDetailUpdate() {
     this.mainHeader?.reload();
     this.settingsView?.reloadAccountDetail();
@@ -752,14 +694,6 @@
     this.registrationModal.close();
   }
 
-  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}`;
-  }
-
   private logWelcome() {
     console.group('Runtime Info');
     console.info('Gerrit UI (PolyGerrit)');
diff --git a/polygerrit-ui/app/elements/gr-app-element_test.ts b/polygerrit-ui/app/elements/gr-app-element_test.ts
new file mode 100644
index 0000000..6f2ea7e
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-element_test.ts
@@ -0,0 +1,61 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../test/common-test-setup';
+import './gr-app';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrAppElement} from './gr-app-element';
+
+suite('gr-app-element tests', () => {
+  let element: GrAppElement;
+
+  setup(async () => {
+    element = await fixture<GrAppElement>(
+      html`<gr-app-element></gr-app-element>`
+    );
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-css-mixins> </gr-css-mixins>
+        <gr-endpoint-decorator name="banner"> </gr-endpoint-decorator>
+        <gr-main-header loggedin id="mainHeader" role="banner">
+        </gr-main-header>
+        <main>
+          <div class="errorView" id="errorView">
+            <div class="errorEmoji"></div>
+            <div class="errorText"></div>
+            <div class="errorMoreInfo"></div>
+          </div>
+        </main>
+        <footer>
+          <div>
+            Powered by
+            <a
+              href="https://www.gerritcodereview.com/"
+              rel="noopener noreferrer"
+              target="_blank"
+            >
+              Gerrit Code Review
+            </a>
+            ()
+            <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>
+        <gr-notifications-prompt> </gr-notifications-prompt>
+        <gr-endpoint-decorator name="plugin-overlay"> </gr-endpoint-decorator>
+        <gr-error-manager id="errorManager"> </gr-error-manager>
+        <gr-plugin-host id="plugins"> </gr-plugin-host>
+      `
+    );
+  });
+});
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 d6a14ed..45dc3b6 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.ts
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -11,7 +11,6 @@
  * expose these variables until plugins switch to direct import from polygerrit.
  */
 
-import {GrAnnotation} from '../embed/diff/gr-diff-highlight/gr-annotation';
 import {GrPluginActionContext} from './shared/gr-js-api-interface/gr-plugin-action-context';
 import {AppContext, injectAppContext} from '../services/app-context';
 import {PluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader';
@@ -21,8 +20,9 @@
   initErrorReporter,
   initWebVitals,
   initClickReporter,
+  initInteractionReporter,
 } from '../services/gr-reporting/gr-reporting_impl';
-import {Finalizable} from '../services/registry';
+import {Finalizable} from '../types/types';
 
 export function initGlobalVariables(
   appContext: AppContext & Finalizable,
@@ -36,8 +36,8 @@
     initWebVitals(reportingService);
     initErrorReporter(reportingService);
     initClickReporter(reportingService);
+    initInteractionReporter(reportingService);
   }
-  window.GrAnnotation = GrAnnotation;
   window.GrPluginActionContext = GrPluginActionContext;
 }
 
diff --git a/polygerrit-ui/app/elements/gr-app.ts b/polygerrit-ui/app/elements/gr-app.ts
index 40869a9..2ea6983 100644
--- a/polygerrit-ui/app/elements/gr-app.ts
+++ b/polygerrit-ui/app/elements/gr-app.ts
@@ -24,7 +24,6 @@
 
 import {initGerrit, initGlobalVariables} from './gr-app-global-var-init';
 import './gr-app-element';
-import {Finalizable} from '../services/registry';
 import {
   DependencyError,
   DependencyToken,
@@ -46,6 +45,7 @@
 } from '../services/service-worker-installer';
 import {pluginLoaderToken} from './shared/gr-js-api-interface/gr-plugin-loader';
 import {getAppContext} from '../services/app-context';
+import {Finalizable} from '../types/types';
 
 initGlobalVariables(createAppContext(), true);
 
diff --git a/polygerrit-ui/app/elements/lit/incremental-repeat.ts b/polygerrit-ui/app/elements/lit/incremental-repeat.ts
index 695290c..bcfb682 100644
--- a/polygerrit-ui/app/elements/lit/incremental-repeat.ts
+++ b/polygerrit-ui/app/elements/lit/incremental-repeat.ts
@@ -17,18 +17,24 @@
   initialCount: number;
   targetFrameRate?: number;
   startAt?: number;
-  // TODO: targetFramerate
+  endAt?: number;
 }
 
 interface RepeatState<T> {
   values: T[];
   mapFn?: (val: T, idx: number) => unknown;
   startAt: number;
+  endAt: number;
   incrementAmount: number;
   lastRenderedAt: number;
   targetFrameRate: number;
 }
 
+// This directive supports incrementally rendering a list of elements.
+// It only responds to updates to values (which forces a complete re-render) and
+// an update to endAt (which expands the list).
+// It currently does not support changes to mapFn, initialCount or startAt
+// unless values are also changed.
 class IncrementalRepeat<T> extends AsyncDirective {
   private children: {part: ChildPart; options: RepeatOptions<T>}[] = [];
 
@@ -36,11 +42,14 @@
 
   private state!: RepeatState<T>;
 
+  // Will render from `options.startAt` to `options.endAt`, up to
+  // `options.initialCount` elements.
   render(options: RepeatOptions<T>) {
-    const values = options.values.slice(
-      options.startAt ?? 0,
-      (options.startAt ?? 0) + options.initialCount
-    );
+    const start = options.startAt ?? 0;
+    const offset = start + options.initialCount;
+    const end =
+      options.endAt === undefined ? offset : Math.min(options.endAt, offset);
+    const values = options.values.slice(start, end);
     if (options.mapFn) {
       return values.map(options.mapFn);
     }
@@ -57,6 +66,7 @@
         values: options.values,
         mapFn: options.mapFn,
         startAt: options.initialCount,
+        endAt: options.endAt ?? options.values.length,
         incrementAmount: options.initialCount,
         lastRenderedAt: performance.now(),
         targetFrameRate: options.targetFrameRate ?? 30,
@@ -66,7 +76,19 @@
       );
     } else {
       this.updateParts();
+      // TODO: Deal with updates to startAt by removing children and then
+      // trimming the child where the new startAt falls into.
+      if ((options.endAt ?? options.values.length) >= this.state.endAt) {
+        this.state.endAt = options.endAt ?? options.values.length;
+        if (this.nextScheduledFrameWork) {
+          cancelAnimationFrame(this.nextScheduledFrameWork);
+        }
+        this.nextScheduledFrameWork = requestAnimationFrame(
+          this.animationFrameHandler
+        );
+      }
     }
+    // Render the first initial count.
     return this.render(options);
   }
 
@@ -92,6 +114,10 @@
   private nextScheduledFrameWork: number | undefined;
 
   private animationFrameHandler = () => {
+    if (this.state.startAt >= this.state.endAt) {
+      this.nextScheduledFrameWork = undefined;
+      return;
+    }
     const now = performance.now();
     const frameRate = 1000 / (now - this.state.lastRenderedAt);
     if (frameRate < this.state.targetFrameRate) {
@@ -109,13 +135,16 @@
       values: this.state.values,
       initialCount: this.state.incrementAmount,
       startAt: this.state.startAt,
+      endAt: this.state.endAt,
     });
 
     this.state.startAt += this.state.incrementAmount;
-    if (this.state.startAt < this.state.values.length) {
+    if (this.state.startAt < this.state.endAt) {
       this.nextScheduledFrameWork = requestAnimationFrame(
         this.animationFrameHandler
       );
+    } else {
+      this.nextScheduledFrameWork = undefined;
     }
   };
 }
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-screen/gr-plugin-screen.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-screen/gr-plugin-screen.ts
new file mode 100644
index 0000000..fd702b1
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-screen/gr-plugin-screen.ts
@@ -0,0 +1,61 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../gr-endpoint-decorator/gr-endpoint-decorator';
+import '../gr-endpoint-param/gr-endpoint-param';
+import {LitElement, html} from 'lit';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {pluginViewModelToken} from '../../../models/views/plugin';
+import {customElement, state} from 'lit/decorators.js';
+import {keyed} from 'lit/directives/keyed.js';
+
+@customElement('gr-plugin-screen')
+export class GrPluginScreen extends LitElement {
+  @state() screen?: string;
+
+  @state() screenName?: string;
+
+  private readonly getPluginViewModel = resolve(this, pluginViewModelToken);
+
+  constructor() {
+    super();
+
+    subscribe(
+      this,
+      () => this.getPluginViewModel().state$,
+      state => {
+        this.screen = state.screen;
+      }
+    );
+    subscribe(
+      this,
+      () => this.getPluginViewModel().screenName$,
+      screenName => {
+        this.screenName = screenName;
+      }
+    );
+  }
+
+  override render() {
+    return keyed(
+      this.screenName,
+      html`
+        <gr-endpoint-decorator .name=${this.screenName}>
+          <gr-endpoint-param
+            name="token"
+            .value=${this.screen}
+          ></gr-endpoint-param>
+        </gr-endpoint-decorator>
+      `
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-plugin-screen': GrPluginScreen;
+  }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-screen/gr-plugin-screen_test.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-screen/gr-plugin-screen_test.ts
new file mode 100644
index 0000000..10dff23
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-screen/gr-plugin-screen_test.ts
@@ -0,0 +1,60 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import '../../gr-app';
+import {fixture, html, assert} from '@open-wc/testing';
+import {queryAndAssert} from '../../../utils/common-util';
+import {screenName} from '../../../models/views/plugin';
+import {GrEndpointDecorator} from '../gr-endpoint-decorator/gr-endpoint-decorator';
+import {GrPluginScreen} from './gr-plugin-screen';
+
+suite('gr-plugin-screen', () => {
+  let element: GrPluginScreen;
+
+  setup(async () => {
+    element = await fixture<GrPluginScreen>(
+      html`<gr-plugin-screen></gr-plugin-screen>`
+    );
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-endpoint-decorator>
+          <gr-endpoint-param name="token"></gr-endpoint-param>
+        </gr-endpoint-decorator>
+      `
+    );
+  });
+
+  test('renders plugin screen, changes endpoint instance', async () => {
+    element.screen = 'test-screen-1';
+    element.screenName = screenName('test-plugin', element.screen);
+    await element.updateComplete;
+
+    const endpoint1 = queryAndAssert<GrEndpointDecorator>(
+      element,
+      'gr-endpoint-decorator'
+    );
+    assert.equal(endpoint1.name, 'test-plugin-screen-test-screen-1');
+
+    element.screen = 'test-screen-2';
+    element.screenName = screenName('test-plugin', element.screen);
+    await element.updateComplete;
+
+    const endpoint2 = queryAndAssert<GrEndpointDecorator>(
+      element,
+      'gr-endpoint-decorator'
+    );
+    assert.equal(endpoint2.name, 'test-plugin-screen-test-screen-2');
+
+    // Plugin screen endpoints have a variable name. Lit must not re-use the
+    // same element instance. (Issue 16884)
+    assert.isFalse(endpoint1 === endpoint2);
+  });
+});
diff --git a/polygerrit-ui/app/elements/plugins/gr-suggestions-api/gr-suggestions-api.ts b/polygerrit-ui/app/elements/plugins/gr-suggestions-api/gr-suggestions-api.ts
new file mode 100644
index 0000000..4006849
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-suggestions-api/gr-suggestions-api.ts
@@ -0,0 +1,48 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {PluginApi} from '../../../api/plugin';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {PluginsModel} from '../../../models/plugins/plugins-model';
+import {
+  SuggestionsPluginApi,
+  SuggestionsProvider,
+} from '../../../api/suggestions';
+
+enum State {
+  NOT_REGISTERED,
+  REGISTERED,
+}
+
+/**
+ * Plugin API for suggestions.
+ *
+ * This object is returned to plugins that want to provide suggestions data.
+ * Plugins normally just call register() once at startup and then wait for
+ * suggestCode() being called on the provider interface.
+ */
+export class GrSuggestionsApi implements SuggestionsPluginApi {
+  private state = State.NOT_REGISTERED;
+
+  constructor(
+    private readonly reporting: ReportingService,
+    private readonly pluginsModel: PluginsModel,
+    readonly plugin: PluginApi
+  ) {
+    this.reporting.trackApi(this.plugin, 'suggestions', 'constructor');
+  }
+
+  register(provider: SuggestionsProvider): void {
+    this.reporting.trackApi(this.plugin, 'suggestions', 'register');
+    if (this.state === State.REGISTERED) {
+      throw new Error('Only one provider can be registered per plugin.');
+    }
+    this.state = State.REGISTERED;
+    this.pluginsModel.suggestionsRegister({
+      pluginName: this.plugin.getPluginName(),
+      provider,
+    });
+  }
+}
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 89513e3..e25b738 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
@@ -19,9 +19,14 @@
 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 {grFormStyles} from '../../../styles/gr-form-styles';
 import {when} from 'lit/directives/when.js';
 import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
+import {formStyles} from '../../../styles/form-styles';
+import {getDocUrl} from '../../../utils/url-util';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
 
 @customElement('gr-account-info')
 export class GrAccountInfo extends LitElement {
@@ -58,43 +63,59 @@
 
   @state() private avatarChangeUrl = '';
 
+  @state() private docsBaseUrl = '';
+
   private readonly restApiService = getAppContext().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);
-      }
-    `,
-  ];
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      grFormStyles,
+      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);
+        }
+      `,
+    ];
+  }
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().docsBaseUrl$,
+      docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
+    );
+  }
 
   override render() {
     if (!this.account || this.loading) return nothing;
@@ -103,8 +124,7 @@
         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"
+        <a href=${getDocUrl(this.docsBaseUrl, 'user-privacy.html')}
           >Learn more</a
         >
       </p>
@@ -225,7 +245,7 @@
         <span class="value">
           <iron-autogrow-textarea
             id="statusInput"
-            .name=${'statusInput'}
+            .label=${'statusInput'}
             ?disabled=${this.saving}
             maxlength="140"
             .value=${this.account?.status}
@@ -339,6 +359,10 @@
       });
   }
 
+  delete() {
+    return this.restApiService.deleteAccount();
+  }
+
   private maybeSetName() {
     // Note that we are intentionally not acting on this._account.name being the
     // empty string (which is falsy).
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 9f4379b..204071e 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
@@ -6,7 +6,7 @@
 import {getBaseUrl} from '../../../utils/url-util';
 import {ContributorAgreementInfo} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
-import {formStyles} from '../../../styles/gr-form-styles';
+import {grFormStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {css, html, LitElement} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
@@ -41,7 +41,7 @@
           width: auto;
         }
       `,
-      formStyles,
+      grFormStyles,
     ];
   }
 
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 53548f8..ea20b54 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
@@ -5,12 +5,10 @@
  */
 import '../../shared/gr-button/gr-button';
 import {ServerInfo} from '../../../types/common';
-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 {grFormStyles} from '../../../styles/gr-form-styles';
 import {fire} from '../../../utils/event-util';
 import {ValueChangedEvent} from '../../../types/events';
 import {ColumnNames} from '../../../constants/constants';
@@ -27,37 +25,37 @@
   showNumber?: boolean;
 
   @property({type: Array})
-  defaultColumns: string[] = [];
+  defaultColumns: string[] = Object.values(ColumnNames);
 
   @state()
   serverConfig?: ServerInfo;
 
-  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);
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      sharedStyles,
+      grFormStyles,
+      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();
@@ -116,34 +114,6 @@
     </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)
-    );
-  }
-
-  /**
-   * Is the column disabled by a server config or experiment?
-   * private but used in test
-   */
-  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).
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 4d3d3a1..9efebef 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
@@ -21,14 +21,14 @@
     );
 
     columns = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Reviewers',
-      'Comments',
-      'Repo',
+      ColumnNames.SUBJECT,
+      ColumnNames.OWNER,
+      ColumnNames.REVIEWERS,
+      ColumnNames.REPO,
       ColumnNames.BRANCH,
       ColumnNames.UPDATED,
+      ColumnNames.SIZE,
+      // ColumnNames.STATUS omitted for testing
     ];
 
     element.displayedColumns = columns;
@@ -99,13 +99,13 @@
             <tr>
               <td><label for="Size"> Size </label></td>
               <td class="checkboxContainer">
-                <input id="Size" name="Size" type="checkbox" />
+                <input checked="" id="Size" name="Size" type="checkbox" />
               </td>
             </tr>
             <tr>
-              <td><label for=" Status "> Status </label></td>
+              <td><label for="Status"> Status </label></td>
               <td class="checkboxContainer">
-                <input id=" Status " name=" Status " type="checkbox" />
+                <input id="Status" name="Status" type="checkbox" />
               </td>
             </tr>
           </tbody>
@@ -170,9 +170,7 @@
   });
 
   test('getDisplayedColumns', () => {
-    const enabledColumns = columns.filter(column =>
-      element.isColumnEnabled(column)
-    );
+    const enabledColumns = columns;
     assert.deepEqual(element.getDisplayedColumns(), enabledColumns);
     const input = queryAndAssert<HTMLInputElement>(
       element,
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 a2b61e0..424f08e 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
@@ -14,7 +14,7 @@
 import {fireAlert, fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {fontStyles} from '../../../styles/gr-font-styles';
-import {formStyles} from '../../../styles/gr-form-styles';
+import {grFormStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, html, css} from 'lit';
 import {customElement, state} from 'lit/decorators.js';
@@ -59,7 +59,7 @@
   static override get styles() {
     return [
       fontStyles,
-      formStyles,
+      grFormStyles,
       sharedStyles,
       css`
         h1 {
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 183425d..d1a9932 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
@@ -7,7 +7,7 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-select/gr-select';
 import {EditPreferencesInfo} from '../../../types/common';
-import {formStyles} from '../../../styles/gr-form-styles';
+import {grFormStyles} 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';
@@ -65,7 +65,7 @@
     return [
       sharedStyles,
       menuPageStyles,
-      formStyles,
+      grFormStyles,
       css`
         :host {
           border: none;
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 b9f59bf..9c99ae0 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
@@ -10,7 +10,7 @@
 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 {grFormStyles} from '../../../styles/gr-form-styles';
 import {ValueChangedEvent} from '../../../types/events';
 import {fire} from '../../../utils/event-util';
 
@@ -29,35 +29,37 @@
 
   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);
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      sharedStyles,
+      grFormStyles,
+      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">
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 32b32e2..61cc9ed 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
@@ -12,12 +12,13 @@
 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 {grFormStyles} 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';
+import {formStyles} from '../../../styles/form-styles';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -49,34 +50,37 @@
 
   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);
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      grFormStyles,
+      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`
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 68a2293..bfff989 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
@@ -5,7 +5,7 @@
  */
 import {GroupInfo, GroupId} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
-import {formStyles} from '../../../styles/gr-form-styles';
+import {grFormStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
 import {customElement, state} from 'lit/decorators.js';
@@ -35,7 +35,7 @@
   static override get styles() {
     return [
       sharedStyles,
-      formStyles,
+      grFormStyles,
       css`
         #groups .nameColumn {
           min-width: 11em;
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 16e262b..47d992f 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
@@ -6,7 +6,7 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import {getAppContext} from '../../../services/app-context';
-import {formStyles} from '../../../styles/gr-form-styles';
+import {grFormStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, css, html} from 'lit';
 import {customElement, property, query} from 'lit/decorators.js';
@@ -66,7 +66,7 @@
   static override get styles() {
     return [
       sharedStyles,
-      formStyles,
+      grFormStyles,
       modalStyles,
       css`
         .password {
@@ -114,7 +114,11 @@
           >
         </div>
         <span ?hidden=${!this._passwordUrl}>
-          <a href=${this._passwordUrl!} target="_blank" rel="noopener">
+          <a
+            href=${this._passwordUrl!}
+            target="_blank"
+            rel="noopener noreferrer"
+          >
             Obtain password</a
           >
           (opens in a new tab)
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 a582044..7fe3be6 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
@@ -53,7 +53,9 @@
             </gr-button>
           </div>
           <span hidden="">
-            <a href="" rel="noopener" target="_blank"> Obtain password </a>
+            <a href="" target="_blank" rel="noopener noreferrer">
+              Obtain password
+            </a>
             (opens in a new tab)
           </span>
         </div>
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 7f67ea8..c4f7bca 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
@@ -12,7 +12,7 @@
 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 {grFormStyles} 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';
@@ -36,37 +36,39 @@
 
   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);
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      sharedStyles,
+      grFormStyles,
+      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">
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 9c23857..bd7659b 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
@@ -8,7 +8,7 @@
 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 {grFormStyles} from '../../../styles/gr-form-styles';
 import {state, customElement} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 import {subscribe} from '../../lit/subscription-controller';
@@ -48,31 +48,33 @@
     );
   }
 
-  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;
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      grFormStyles,
+      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;
+        }
+      `,
+    ];
+  }
 
   override render() {
     const unchanged = deepEqual(this.menuItems, this.originalPrefs.my);
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 c6c023e..e55943a 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
@@ -13,7 +13,7 @@
 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 {grFormStyles} 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';
@@ -68,50 +68,52 @@
     }
   }
 
-  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;
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      sharedStyles,
+      grFormStyles,
+      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">
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 ea65542..c7118f7 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
@@ -5,7 +5,7 @@
  */
 import {LitElement, css, html} from 'lit';
 import {customElement, property} from 'lit/decorators.js';
-import {formStyles} from '../../../styles/gr-form-styles';
+import {grFormStyles} from '../../../styles/gr-form-styles';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -23,7 +23,7 @@
 
   static override get styles() {
     return [
-      formStyles,
+      grFormStyles,
       css`
         :host {
           display: block;
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 fc61ba0..4d4834e 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
@@ -23,6 +23,7 @@
 import '../gr-menu-editor/gr-menu-editor';
 import '../gr-ssh-editor/gr-ssh-editor';
 import '../gr-watched-projects-editor/gr-watched-projects-editor';
+import '../../shared/gr-dialog/gr-dialog';
 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';
@@ -39,7 +40,6 @@
 import {fireAlert, fireTitleChange} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {
-  ColumnNames,
   DateFormat,
   DefaultBase,
   DiffViewMode,
@@ -57,19 +57,20 @@
 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 {grFormStyles} 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';
-const GERRIT_DOCS_FILTER_PATH = '/user-notify.html';
-const ABSOLUTE_URL_PATTERN = /^https?:/;
-const TRAILING_SLASH_PATTERN = /\/$/;
+import {
+  changeTablePrefs,
+  userModelToken,
+} from '../../../models/user/user-model';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {getDocUrl, rootUrl} from '../../../utils/url-util';
+import {configModelToken} from '../../../models/config/config-model';
 
 const HTTP_AUTH = ['HTTP', 'HTTP_LDAP'];
 
@@ -88,6 +89,9 @@
 
   @query('#accountInfo', true) accountInfo!: GrAccountInfo;
 
+  @query('#confirm-account-deletion')
+  private deleteAccountConfirmationDialog?: HTMLDialogElement;
+
   @query('#watchedProjectsEditor', true)
   watchedProjectsEditor!: GrWatchedProjectsEditor;
 
@@ -178,9 +182,6 @@
   // 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
@@ -191,6 +192,10 @@
 
   @state() account?: AccountDetailInfo;
 
+  @state() isDeletingAccount = false;
+
+  @state() private docsBaseUrl = '';
+
   // private but used in test
   public _testOnly_loadingPromise?: Promise<void>;
 
@@ -203,6 +208,10 @@
 
   private readonly getViewModel = resolve(this, settingsViewModelToken);
 
+  private readonly getNavigation = resolve(this, navigationToken);
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
   constructor() {
     super();
     subscribe(
@@ -231,14 +240,14 @@
         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
-              );
+        this.localChangeTableColumns = changeTablePrefs(prefs);
       }
     );
+    subscribe(
+      this,
+      () => this.getConfigModel().docsBaseUrl$,
+      docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
+    );
   }
 
   // private, but used in tests
@@ -283,12 +292,6 @@
           );
         }
 
-        configPromises.push(
-          this.restApiService.getDocsBaseUrl(config).then(baseUrl => {
-            this.docsBaseUrl = baseUrl;
-          })
-        );
-
         return Promise.all(configPromises);
       })
     );
@@ -303,43 +306,53 @@
     });
   }
 
-  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);
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      sharedStyles,
+      paperStyles,
+      fontStyles,
+      grFormStyles,
+      modalStyles,
+      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);
+        }
+        .delete-account-button {
+          margin-left: var(--spacing-l);
+        }
+        .confirm-account-deletion-main ul {
+          list-style: disc inside;
+          margin-left: var(--spacing-l);
+        }
+      `,
+    ];
+  }
 
   override render() {
     const isLoading = this.loading || this.loading === undefined;
@@ -374,7 +387,6 @@
               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>
@@ -402,6 +414,32 @@
               ?disabled=${!this.accountInfoChanged}
               >Save changes</gr-button
             >
+            <gr-button
+              class="delete-account-button"
+              @click=${() => {
+                this.confirmDeleteAccount();
+              }}
+              >Delete Account</gr-button
+            >
+            <dialog id="confirm-account-deletion">
+              <gr-dialog
+                @cancel=${() => this.deleteAccountConfirmationDialog?.close()}
+                @confirm=${() => this.deleteAccount()}
+                .loading=${this.isDeletingAccount}
+                .loadingLabel=${'Deleting account'}
+                .confirmLabel=${'Delete account'}
+              >
+                <div class="confirm-account-deletion-header" slot="header">
+                  Are you sure you wish to delete your account?
+                </div>
+                <div class="confirm-account-deletion-main" slot="main">
+                  <ul>
+                    <li>Deleting your account is not reversible.</li>
+                    <li>Deleting your account will not delete your changes.</li>
+                  </ul>
+                </div>
+              </gr-dialog>
+            </dialog>
           </fieldset>
           <h2
             id="Preferences"
@@ -633,91 +671,6 @@
                 <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>
@@ -888,8 +841,12 @@
             >Allow browser notifications</label
           >
           <a
-            href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html#_browser_notifications"
+            href=${getDocUrl(
+              this.docsBaseUrl,
+              'user-attention-set.html#_browser_notifications'
+            )}
             target="_blank"
+            rel="noopener noreferrer"
           >
             <gr-icon icon="help" title="read documentation"></gr-icon>
           </a>
@@ -1197,17 +1154,16 @@
     });
   }
 
-  // private but used in test
-  getFilterDocsLink(docsBaseUrl?: string | null) {
-    let base = docsBaseUrl;
-    if (!base || !ABSOLUTE_URL_PATTERN.test(base)) {
-      base = GERRIT_DOCS_BASE_URL;
-    }
+  private confirmDeleteAccount() {
+    this.deleteAccountConfirmationDialog?.showModal();
+  }
 
-    // Remove any trailing slash, since it is in the GERRIT_DOCS_FILTER_PATH.
-    base = base.replace(TRAILING_SLASH_PATTERN, '');
-
-    return base + GERRIT_DOCS_FILTER_PATH;
+  private async deleteAccount() {
+    this.isDeletingAccount = true;
+    await this.accountInfo.delete();
+    this.isDeletingAccount = false;
+    this.deleteAccountConfirmationDialog?.close();
+    this.getNavigation().setUrl(rootUrl());
   }
 
   // private but used in test
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 0bcb09c..f9b1738 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
@@ -121,10 +121,6 @@
   });
 
   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(
@@ -148,7 +144,6 @@
             <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>
@@ -166,6 +161,37 @@
             >
               Save changes
             </gr-button>
+            <gr-button
+              aria-disabled="false"
+              class="delete-account-button"
+              role="button"
+              tabindex="0"
+            >
+              Delete Account
+            </gr-button>
+            <dialog id="confirm-account-deletion">
+            <gr-dialog role="dialog">
+              <div
+                class="confirm-account-deletion-header"
+                slot="header"
+              >
+              Are you sure you wish to delete your account?
+              </div>
+              <div
+                class="confirm-account-deletion-main"
+                slot="main"
+              >
+                <ul>
+                  <li>
+                    Deleting your account is not reversible.
+                  </li>
+                  <li>
+                    Deleting your account will not delete your changes.
+                  </li>
+                </ul>
+              </div>
+            </gr-dialog>
+          </dialog>
           </fieldset>
           <h2 id="Preferences">Preferences</h2>
           <fieldset id="preferences">
@@ -416,99 +442,13 @@
             >
               Send verification
             </gr-button>
-          </fieldset> 
+          </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>
@@ -529,8 +469,9 @@
             Allow browser notifications
           </label>
           <a
-            href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html#_browser_notifications"
+            href="/Documentation/user-attention-set.html#_browser_notifications"
             target="_blank"
+            rel="noopener noreferrer"
           >
             <gr-icon icon="help" title="read documentation"> </gr-icon>
           </a>
@@ -832,45 +773,6 @@
     assert.isFalse(element.showHttpAuth());
   });
 
-  suite('getFilterDocsLink', () => {
-    test('with http: docs base URL', () => {
-      const base = 'http://example.com/';
-      const result = element.getFilterDocsLink(base);
-      assert.equal(result, 'http://example.com/user-notify.html');
-    });
-
-    test('with http: docs base URL without slash', () => {
-      const base = 'http://example.com';
-      const result = element.getFilterDocsLink(base);
-      assert.equal(result, 'http://example.com/user-notify.html');
-    });
-
-    test('with https: docs base URL', () => {
-      const base = 'https://example.com/';
-      const result = element.getFilterDocsLink(base);
-      assert.equal(result, 'https://example.com/user-notify.html');
-    });
-
-    test('without docs base URL', () => {
-      const result = element.getFilterDocsLink(null);
-      assert.equal(
-        result,
-        'https://gerrit-review.googlesource.com/' +
-          'Documentation/user-notify.html'
-      );
-    });
-
-    test('ignores non HTTP links', () => {
-      const base = 'javascript://alert("evil");';
-      const result = element.getFilterDocsLink(base);
-      assert.equal(
-        result,
-        'https://gerrit-review.googlesource.com/' +
-          'Documentation/user-notify.html'
-      );
-    });
-  });
-
   suite('when email verification token is provided', () => {
     let resolveConfirm: (
       value: string | PromiseLike<string | null> | null
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 9c323aa..9760dfd 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
@@ -12,7 +12,7 @@
 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 {grFormStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {fire} from '../../../utils/event-util';
 import {BindValueChangeEvent} from '../../../types/events';
@@ -52,7 +52,7 @@
 
   static override get styles() {
     return [
-      formStyles,
+      grFormStyles,
       sharedStyles,
       modalStyles,
       css`
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 2996e50..f0f0f2f 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
@@ -17,7 +17,7 @@
 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 {grFormStyles} from '../../../styles/gr-form-styles';
 import {when} from 'lit/directives/when.js';
 import {fire} from '../../../utils/event-util';
 import {PropertiesOfType} from '../../../utils/type-util';
@@ -60,7 +60,7 @@
   static override get styles() {
     return [
       sharedStyles,
-      formStyles,
+      grFormStyles,
       css`
         #watchedProjects .notifType {
           text-align: center;
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 cf7ff2209..6edf0fc53 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
@@ -10,7 +10,11 @@
 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 {
+  isDetailedAccount,
+  isSelf,
+  isServiceUser,
+} from '../../../utils/account-util';
 import {ChangeInfo, AccountInfo, ServerInfo} from '../../../types/common';
 import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
 import {fire} from '../../../utils/event-util';
@@ -191,8 +195,21 @@
 
   override async updated() {
     assertIsDefined(this.account, 'account');
+    if (isDetailedAccount(this.account)) return;
     const account = await this.getAccountsModel().fillDetails(this.account);
-    if (account) this.account = account;
+    if (!isDetailedAccount(account)) return;
+    // AccountInfo returned by fillDetails has the email property set
+    // to the primary email of the account. This poses a problem in
+    // cases where a secondary email is used as the committer or author
+    // email. Therefore, only fill in the *missing* properties.
+    if (
+      account &&
+      account !== this.account &&
+      (!this.account._account_id ||
+        account._account_id === this.account._account_id)
+    ) {
+      this.account = {...account, ...this.account};
+    }
   }
 
   override render() {
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 965a9c4..e4490a2 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
@@ -128,32 +128,34 @@
     );
   }
 
-  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;
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      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">
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 fd1311c..210b91c 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -201,59 +201,63 @@
       .inputElement as HTMLInputElement;
   }
 
-  static override styles = [
-    sharedStyles,
-    css`
-      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
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        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
+          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);
-        /*
+          --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;
-      }
-    `,
-  ];
+          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();
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
index 1bfb55b..863ee90 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
@@ -4,16 +4,22 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import './gr-avatar';
-import {AccountInfo} from '../../../types/common';
-import {LitElement, css, html} from 'lit';
+import '../gr-hovercard-account/gr-hovercard-account';
+import {AccountInfo, ServerInfo} from '../../../types/common';
+import {LitElement, PropertyValues, css, html} from 'lit';
 import {customElement, property, state} from 'lit/decorators.js';
 import {
+  isDetailedAccount,
   uniqueAccountId,
   uniqueDefinedAvatar,
 } from '../../../utils/account-util';
 import {resolve} from '../../../models/dependency';
 import {configModelToken} from '../../../models/config/config-model';
 import {subscribe} from '../../lit/subscription-controller';
+import {getDisplayName} from '../../../utils/display-name-util';
+import {accountsModelToken} from '../../../models/accounts-model/accounts-model';
+import {isDefined} from '../../../types/types';
+import {when} from 'lit/directives/when.js';
 
 /**
  * This elements draws stack of avatars overlapped with each other.
@@ -33,6 +39,9 @@
   @property({type: Array})
   accounts: AccountInfo[] = [];
 
+  @state()
+  detailedAccounts: AccountInfo[] = [];
+
   /**
    * The size of requested image in px.
    *
@@ -42,18 +51,23 @@
   imageSize = 16;
 
   /**
+   * Whether a hover-card should be shown for each avatar when hovered
+   */
+  @property({type: Boolean})
+  enableHover = false;
+
+  /**
    * 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.
+   * gr-avatar-stack that will fetch the accounts on demand
    */
   @property({type: Boolean})
   forceFetch = false;
 
-  /**
-   * Reflects plugins.has_avatars value of server configuration.
-   */
-  @state() private hasAvatars = false;
+  private readonly getAccountsModel = resolve(this, accountsModelToken);
+
+  @state() config?: ServerInfo;
 
   static override get styles() {
     return [
@@ -79,20 +93,44 @@
     subscribe(
       this,
       () => this.getConfigModel().serverConfig$,
-      config => {
-        this.hasAvatars = Boolean(config?.plugin?.has_avatars);
-      }
+      config => (this.config = config)
     );
   }
 
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('accounts')) {
+      if (
+        this.forceFetch &&
+        this.accounts.length > 0 &&
+        this.accounts.some(a => !isDetailedAccount(a))
+      ) {
+        Promise.all(
+          this.accounts.map(account =>
+            this.getAccountsModel().fillDetails(account)
+          )
+        ).then(accounts => {
+          // Only keep the detailed accounts as only those will be shown.
+          // It is possible for the server to return an empty account with just an account-id.
+          // This could be due to the fact that the user does not have permission to see this account.
+          this.detailedAccounts = accounts.filter(
+            a => isDefined(a) && isDetailedAccount(a)
+          );
+        });
+      } else {
+        this.detailedAccounts = this.accounts;
+      }
+    }
+  }
+
   override render() {
     const uniqueAvatarAccounts = this.forceFetch
-      ? this.accounts.filter(uniqueAccountId)
-      : this.accounts
+      ? this.detailedAccounts.filter(uniqueAccountId)
+      : this.detailedAccounts
           .filter(account => !!account?.avatars?.[0]?.url)
           .filter(uniqueDefinedAvatar);
+    const hasAvatars = this.config?.plugin?.has_avatars ?? false;
     if (
-      !this.hasAvatars ||
+      !hasAvatars ||
       uniqueAvatarAccounts.length === 0 ||
       uniqueAvatarAccounts.length > GrAvatarStack.MAX_STACK
     ) {
@@ -101,10 +139,17 @@
     return uniqueAvatarAccounts.map(
       account =>
         html`<gr-avatar
-          .forceFetch=${this.forceFetch}
           .account=${account}
           .imageSize=${this.imageSize}
+          aria-label=${getDisplayName(this.config, account)}
         >
+          ${when(
+            this.enableHover,
+            () =>
+              html`<gr-hovercard-account
+                .account=${account}
+              ></gr-hovercard-account>`
+          )}
         </gr-avatar>`
     );
   }
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
index c186a48..a427924 100644
--- 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
@@ -12,6 +12,7 @@
 import {fixture, html, assert} from '@open-wc/testing';
 import {stubRestApi} from '../../../test/test-utils';
 import {LitElement} from 'lit';
+import {AccountId, Timestamp} from '../../../api/rest-api';
 
 suite('gr-avatar tests', () => {
   suite('config with avatars', () => {
@@ -60,10 +61,14 @@
       assert.shadowDom.equal(
         element,
         /* HTML */ `<gr-avatar
+            aria-label="0"
             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
+            aria-label="1"
+            style='background-image: url("https://a.b.c/photo1.jpg");'
+          >
           </gr-avatar> `
       );
       // Verify that margins are set correctly.
@@ -78,6 +83,131 @@
       }
     });
 
+    test('renders avatars and hovercards', 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}
+          .enableHover=${true}
+        ></gr-avatar-stack>`
+      );
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `<gr-avatar
+            aria-label="0"
+            style='background-image: url("https://a.b.c/photo0.jpg");'
+          >
+            <gr-hovercard-account></gr-hovercard-account>
+          </gr-avatar>
+          <gr-avatar
+            aria-label="1"
+            style='background-image: url("https://a.b.c/photo1.jpg");'
+          >
+            <gr-hovercard-account></gr-hovercard-account>
+          </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('fetches account details. avatars', async () => {
+      const stub = stubRestApi('getAccountDetails').resolves({
+        ...createAccountWithId(1),
+        avatars: [
+          {
+            url: 'https://a.b.c/photo0.jpg',
+            height: 32,
+            width: 32,
+          },
+        ],
+        registered_on: '1234' as Timestamp,
+      });
+      const element: LitElement = await fixture(
+        html`<gr-avatar-stack
+          .accounts=${[{_account_id: 1}]}
+          .forceFetch=${true}
+          .imageSize=${32}
+        ></gr-avatar-stack>`
+      );
+      await element.updateComplete;
+      // The previous `updated` should have started the fetch which fills
+      // in `detailedAccounts` so now we wait for another render cycle.
+      await element.updateComplete;
+
+      assert.equal(stub.called, true);
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `<gr-avatar
+          aria-label="1"
+          style='background-image: url("https://a.b.c/photo0.jpg");'
+        >
+        </gr-avatar>`
+      );
+      // Verify that margins are set correctly.
+      const avatars = element.shadowRoot!.querySelectorAll('gr-avatar');
+      assert.strictEqual(avatars.length, 1);
+      assert.strictEqual(window.getComputedStyle(avatars[0]).marginLeft, '0px');
+    });
+
+    test('fetches account details. does not infinite loop', async () => {
+      const stub = stubRestApi('getAccountDetails').resolves({
+        _account_id: 1 as AccountId,
+        registered_on: '1234' as Timestamp,
+      });
+      const element: LitElement = await fixture(
+        html`<gr-avatar-stack
+          .accounts=${[{_account_id: 1}]}
+          .forceFetch=${true}
+          .imageSize=${32}
+        ></gr-avatar-stack>`
+      );
+      await element.updateComplete;
+      // The previous `updated` should have started the fetch which fills
+      // in `detailedAccounts` so now we wait for another render cycle.
+      await element.updateComplete;
+
+      assert.equal(stub.callCount, 1);
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ '<slot name="fallback"></slot>'
+      );
+    });
+
     test('renders many accounts fallback', async () => {
       const accounts = [];
       for (let i = 0; i < 5; ++i) {
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 8cfe2d0..1ea2a64 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
@@ -25,14 +25,6 @@
 
   @state() private hasAvatars = false;
 
-  // 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);
@@ -98,7 +90,7 @@
     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 && !this.forceFetch) {
+    if (avatars.length === 0) {
       return '';
     }
     for (let i = 0; i < avatars.length; i++) {
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 b44a16b..49d3c7c 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -3,6 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import '../gr-icon/gr-icon';
 import '@polymer/paper-button/paper-button';
 import {spinnerStyles} from '../../../styles/gr-spinner-styles';
 import {votingStyles} from '../../../styles/gr-voting-styles';
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 54fb825..d00d54f 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
@@ -3,6 +3,7 @@
  * Copyright 2015 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import '../gr-icon/gr-icon';
 import {ChangeInfo} from '../../../types/common';
 import {
   Shortcut,
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 c93cc97..97fa267 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
@@ -87,6 +87,10 @@
           background-color: var(--status-ready);
           color: var(--status-ready);
         }
+        :host(.revert) .chip {
+          background-color: var(--status-revert);
+          color: var(--status-revert);
+        }
         :host(.revert-created) .chip {
           background-color: var(--status-revert-created);
           color: var(--status-revert-created);
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-model/gr-comment-model.ts b/polygerrit-ui/app/elements/shared/gr-comment-model/gr-comment-model.ts
new file mode 100644
index 0000000..9e112cb
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment-model/gr-comment-model.ts
@@ -0,0 +1,69 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Observable} from 'rxjs';
+import {filter} from 'rxjs/operators';
+import {define} from '../../../models/dependency';
+import {Model} from '../../../models/base/model';
+import {isDefined} from '../../../types/types';
+import {select} from '../../../utils/observable-util';
+import {Comment} from '../../../types/common';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {NumericChangeId, isBase64FileContent} from '../../../api/rest-api';
+import {assert, assertIsDefined} from '../../../utils/common-util';
+import {getContentInCommentRange} from '../../../utils/comment-util';
+import {SpecialFilePath} from '../../../constants/constants';
+
+export interface CommentState {
+  comment?: Comment;
+  commentedText?: string;
+}
+
+const initialState: CommentState = {
+  comment: undefined,
+  commentedText: undefined,
+};
+
+export const commentModelToken = define<CommentModel>('diff-model');
+
+export class CommentModel extends Model<CommentState | undefined> {
+  readonly comment$: Observable<Comment | undefined> = select(
+    this.state$.pipe(filter(isDefined)),
+    commentState => commentState.comment
+  );
+
+  readonly commentedText$: Observable<string | undefined> = select(
+    this.state$.pipe(filter(isDefined)),
+    commentState => commentState.commentedText
+  );
+
+  constructor(private readonly restApiService: RestApiService) {
+    super(initialState);
+  }
+
+  async getCommentedCode(
+    comment?: Comment,
+    changeNum?: NumericChangeId
+  ): Promise<string | undefined> {
+    assertIsDefined(comment, 'comment');
+    assertIsDefined(changeNum, 'changeNum');
+    if (comment.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) return;
+    const file = await this.restApiService.getFileContent(
+      changeNum,
+      comment.path!,
+      comment.patch_set!
+    );
+    assert(
+      !!file && isBase64FileContent(file) && !!file.content,
+      'file content for comment not found'
+    );
+    const commentedText = getContentInCommentRange(file.content, comment);
+    assert(!!commentedText, 'file content for comment not found');
+    this.updateState({
+      commentedText,
+    });
+    return commentedText;
+  }
+}
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 c76f04c..0b03581 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
@@ -44,10 +44,9 @@
   UrlEncodedCommentId,
 } from '../../../types/common';
 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 {DiffLayer, FILE, RenderPreferences} from '../../../api/diff';
 import {
   assert,
   assertIsDefined,
@@ -56,7 +55,7 @@
 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 {anyLineTooLong} from '../../../utils/diff-util';
 import {getUserName} from '../../../utils/display-name-util';
 import {generateAbsoluteUrl} from '../../../utils/url-util';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -74,6 +73,7 @@
 import {createChangeUrl, createDiffUrl} from '../../../models/views/change';
 import {userModelToken} from '../../../models/user/user-model';
 import {highlightServiceToken} from '../../../services/highlight/highlight-service';
+import {noAwait, waitUntil} from '../../../utils/async-util';
 
 declare global {
   interface HTMLElementEventMap {
@@ -112,6 +112,9 @@
   @query('.comment-box')
   commentBox?: HTMLElement;
 
+  @query('gr-comment.draft')
+  draftElement?: GrComment;
+
   @queryAll('gr-comment')
   commentElements?: NodeList;
 
@@ -311,6 +314,8 @@
           font-size: var(--font-size-normal);
           font-weight: var(--font-weight-normal);
           line-height: var(--line-height-normal);
+        }
+        gr-diff#diff {
           /* 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
@@ -419,6 +424,14 @@
     ];
   }
 
+  override connectedCallback(): void {
+    super.connectedCallback();
+    // Add a default click-handler so that clicks don't bubble from a comment to gr-diff-rows.
+    this.addEventListener('click', e => {
+      e.stopPropagation();
+    });
+  }
+
   override render() {
     if (!this.thread) return;
     const dynamicBoxClasses = {
@@ -495,6 +508,7 @@
         : !this.unresolved);
     return html`
       <gr-comment
+        class=${classMap({draft: isDraft(comment)})}
         .comment=${comment}
         .comments=${this.thread!.comments}
         ?initially-collapsed=${initiallyCollapsed}
@@ -646,6 +660,15 @@
         }, 500);
       });
     }
+    if (this.thread && isDraft(this.getFirstComment())) {
+      const msg = this.getFirstComment()?.message ?? '';
+      if (msg.length === 0) this.editDraft();
+    }
+  }
+
+  private async editDraft() {
+    await waitUntil(() => !!this.draftElement);
+    this.draftElement!.edit();
   }
 
   private isDraft() {
@@ -797,6 +820,7 @@
     const newReply = createNewReply(replyingTo, content, unresolved);
     if (userWantsToEdit) {
       this.getCommentsModel().addNewDraft(newReply);
+      noAwait(this.editDraft());
     } else {
       try {
         this.saving = true;
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 1027a62..571a322 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
@@ -120,7 +120,11 @@
               robot-button-disabled=""
               show-patchset=""
             ></gr-comment>
-            <gr-comment robot-button-disabled="" show-patchset=""></gr-comment>
+            <gr-comment
+              class="draft"
+              robot-button-disabled=""
+              show-patchset=""
+            ></gr-comment>
           </div>
         </div>
       `
@@ -145,7 +149,11 @@
         <div id="container">
           <h3 class="assistive-tech-only">Draft Comment thread by Yoda</h3>
           <div class="comment-box" tabindex="0">
-            <gr-comment robot-button-disabled="" show-patchset=""></gr-comment>
+            <gr-comment
+              class="draft"
+              robot-button-disabled=""
+              show-patchset=""
+            ></gr-comment>
           </div>
         </div>
       `
@@ -289,7 +297,7 @@
       /* HTML */ `
         <div class="diff-container">
           <gr-diff
-            class="disable-context-control-buttons hide-line-length-indicator no-left"
+            class="disable-context-control-buttons hide-line-length-indicator"
             id="diff"
             style="--line-limit-marker:100ch; --content-width:none; --diff-max-width:none; --font-size:12px;"
           >
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 27a5590..1e91345 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -14,10 +14,11 @@
 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 '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
 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 {provide, resolve} from '../../../models/dependency';
 import {GrTextarea} from '../gr-textarea/gr-textarea';
 import {
   AccountDetailInfo,
@@ -31,9 +32,11 @@
   isError,
   isDraft,
   isNew,
+  CommentInput,
 } from '../../../types/common';
 import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
 import {
+  convertToCommentInput,
   createUserFixSuggestion,
   getContentInCommentRange,
   getUserSuggestion,
@@ -48,28 +51,35 @@
   ValueChangedEvent,
 } from '../../../types/events';
 import {fire} from '../../../utils/event-util';
-import {assertIsDefined, assert} from '../../../utils/common-util';
+import {assertIsDefined, assert, uuid} 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 {FILE, 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 {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 FILE = 'FILE';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {pluginLoaderToken} from '../gr-js-api-interface/gr-plugin-loader';
+import {
+  CommentModel,
+  commentModelToken,
+} from '../gr-comment-model/gr-comment-model';
+import {formStyles} from '../../../styles/form-styles';
+import {Interaction} from '../../../constants/reporting';
+import {Suggestion} from '../../../api/suggestions';
 
 // visible for testing
 export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
+export const GENERATE_SUGGESTION_DEBOUNCE_DELAY_MS = 1500;
 
 declare global {
   interface HTMLElementEventMap {
@@ -170,6 +180,10 @@
   @property({type: Boolean, attribute: 'permanent-editing-mode'})
   permanentEditingMode = false;
 
+  // Whether to disable autosaving
+  @property({type: Boolean})
+  disableAutoSaving = false;
+
   @state()
   autoSaving?: Promise<DraftInfo>;
 
@@ -190,6 +204,15 @@
   @state()
   unresolved = true;
 
+  @state()
+  generateSuggestion = true;
+
+  @state()
+  generatedSuggestion?: Suggestion;
+
+  @state()
+  generatedReplacementId?: string;
+
   @property({type: Boolean, attribute: 'show-patchset'})
   showPatchset = false;
 
@@ -205,20 +228,27 @@
   @state()
   isOwner = false;
 
+  @state()
+  commentedText?: string;
+
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly reporting = getAppContext().reportingService;
 
-  private readonly flagsService = getAppContext().flagsService;
-
   private readonly getChangeModel = resolve(this, changeModelToken);
 
   private readonly getCommentsModel = resolve(this, commentsModelToken);
 
   private readonly getUserModel = resolve(this, userModelToken);
 
+  private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
+  private readonly flagsService = getAppContext().flagsService;
+
   private readonly shortcuts = new ShortcutController(this);
 
+  private commentModel = new CommentModel(this.restApiService);
+
   /**
    * This is triggered when the user types into the editing textarea. We then
    * debounce it and call autoSave().
@@ -226,6 +256,12 @@
   private autoSaveTrigger$ = new Subject();
 
   /**
+   * This is triggered when the user types into the editing textarea. We then
+   * debounce it and call generateSuggestEdit().
+   */
+  private generateSuggestionTrigger$ = new Subject();
+
+  /**
    * Set to the content of DraftInfo when entering editing mode.
    * Only used for "Cancel".
    */
@@ -239,6 +275,7 @@
 
   constructor() {
     super();
+    provide(this, commentModelToken, () => this.commentModel);
     // Allow the shortcuts to bubble up so that GrReplyDialog can respond to
     // them as well.
     this.shortcuts.addLocal({key: Key.ESC}, () => this.handleEsc(), {
@@ -263,6 +300,9 @@
     this.addEventListener('open-user-suggest-preview', e => {
       this.handleShowFix(e.detail.code);
     });
+    this.addEventListener('add-generated-suggestion', e => {
+      this.handleAddGeneratedSuggestion(e.detail.code);
+    });
     this.messagePlaceholder = 'Mention others with @';
     subscribe(
       this,
@@ -298,6 +338,20 @@
         this.autoSave();
       }
     );
+    if (this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT)) {
+      subscribe(
+        this,
+        () =>
+          this.generateSuggestionTrigger$.pipe(
+            debounceTime(GENERATE_SUGGESTION_DEBOUNCE_DELAY_MS)
+          ),
+        () => {
+          if (this.generateSuggestion) {
+            this.generateSuggestEdit();
+          }
+        }
+      );
+    }
   }
 
   override disconnectedCallback() {
@@ -308,6 +362,7 @@
 
   static override get styles() {
     return [
+      formStyles,
       sharedStyles,
       modalStyles,
       css`
@@ -361,7 +416,7 @@
         .actions,
         .robotActions {
           display: flex;
-          justify-content: flex-end;
+          justify-content: space-between;
           padding-top: 0;
         }
         .robotActions {
@@ -373,10 +428,12 @@
         .action {
           margin-left: var(--spacing-l);
         }
+        .leftActions,
         .rightActions {
           display: flex;
           justify-content: flex-end;
         }
+        .leftActions gr-button,
         .rightActions gr-button {
           --gr-button-padding: 0 var(--spacing-s);
         }
@@ -485,6 +542,14 @@
           color: inherit;
           margin-right: var(--spacing-s);
         }
+        .info {
+          background-color: var(--info-background);
+          padding: var(--spacing-l) var(--spacing-xl);
+        }
+        .info gr-icon {
+          color: var(--selected-foreground);
+          margin-right: var(--spacing-xl);
+        }
       `,
     ];
   }
@@ -515,6 +580,7 @@
             <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
             ${this.renderHumanActions()} ${this.renderRobotActions()}
           </div>
+          ${this.renderGeneratedSuggestionPreview()}
         </div>
       </gr-endpoint-decorator>
       ${this.renderConfirmDialog()}
@@ -712,6 +778,7 @@
           // of the textare instead of needing a dedicated property.
           this.messageText = e.detail.value;
           this.autoSaveTrigger$.next();
+          this.generateSuggestionTrigger$.next();
         }}
       ></gr-textarea>
     `;
@@ -751,16 +818,19 @@
     if (this.collapsed || !isDraft(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 class="leftActions">
+          <div class="action resolve">
+            <label>
+              <input
+                type="checkbox"
+                id="resolvedCheckbox"
+                ?checked=${!this.unresolved}
+                @change=${this.handleToggleResolved}
+              />
+              Resolved
+            </label>
+          </div>
+          ${this.renderGenerateSuggestEditButton()}
         </div>
         ${this.renderDraftActions()}
       </div>
@@ -779,9 +849,6 @@
   }
 
   private renderSuggestEditButton() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
-      return nothing;
-    }
     if (
       !this.editing ||
       this.permanentEditingMode ||
@@ -849,6 +916,123 @@
     `;
   }
 
+  private showGeneratedSuggestion() {
+    return (
+      this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT) &&
+      this.editing &&
+      !this.permanentEditingMode &&
+      this.comment &&
+      this.comment.path !== SpecialFilePath.PATCHSET_LEVEL_COMMENTS &&
+      this.comment.path !== SpecialFilePath.COMMIT_MESSAGE &&
+      this.comment === this.comments?.[0] && // Is first comment
+      (this.comment.range || this.comment.line) && // Disabled for File comments
+      !hasUserSuggestion(this.comment)
+    );
+  }
+
+  private renderGeneratedSuggestionPreview() {
+    if (
+      !this.showGeneratedSuggestion() ||
+      !this.generateSuggestion ||
+      !this.generatedSuggestion
+    )
+      return nothing;
+    // TODO(milutin): This is temporary warning, will be removed, once we are
+    // able to change range of a comment
+    if (this.generatedSuggestion.newRange) {
+      const range = this.generatedSuggestion.newRange;
+      return html`<div class="info">
+        <gr-icon icon="info" filled></gr-icon>
+        There is a suggestion in range (${range.start_line}, ${range.end_line})
+      </div>`;
+    }
+    return html`<gr-suggestion-diff-preview
+      .showAddSuggestionButton=${true}
+      .suggestion=${this.generatedSuggestion?.replacement}
+      .uuid=${this.generatedReplacementId}
+    ></gr-suggestion-diff-preview>`;
+  }
+
+  private renderGenerateSuggestEditButton() {
+    if (!this.showGeneratedSuggestion()) {
+      return nothing;
+    }
+    const numberOfSuggestions = !this.generatedSuggestion ? '' : ' (1)';
+    return html`
+      <div class="action">
+        <label>
+          <input
+            type="checkbox"
+            id="generateSuggestCheckbox"
+            ?checked=${this.generateSuggestion}
+            @change=${() => {
+              this.generateSuggestion = !this.generateSuggestion;
+              if (!this.generateSuggestion) {
+                this.generatedSuggestion = undefined;
+              } else {
+                this.generateSuggestionTrigger$.next();
+              }
+              this.reporting.reportInteraction(
+                this.generateSuggestion
+                  ? Interaction.GENERATE_SUGGESTION_ENABLED
+                  : Interaction.GENERATE_SUGGESTION_DISABLED
+              );
+            }}
+          />
+          Generate Suggestion${numberOfSuggestions}
+        </label>
+      </div>
+    `;
+  }
+
+  private handleAddGeneratedSuggestion(code: string) {
+    const addNewLine = this.messageText.length !== 0;
+    this.messageText += `${
+      addNewLine ? '\n' : ''
+    }${USER_SUGGESTION_START_PATTERN}${code}${'\n```'}`;
+  }
+
+  private async generateSuggestEdit() {
+    const suggestionsPlugins =
+      this.getPluginLoader().pluginsModel.getState().suggestionsPlugins;
+    if (suggestionsPlugins.length === 0) return;
+    if (
+      !this.showGeneratedSuggestion() ||
+      !this.changeNum ||
+      !this.comment ||
+      !this.comment.patch_set ||
+      !this.comment.path ||
+      this.messageText.length === 0
+    )
+      return;
+    this.generatedReplacementId = uuid();
+    this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_REQUEST, {
+      uuid: this.generatedReplacementId,
+    });
+    const suggestionResponse = await suggestionsPlugins[0].provider.suggestCode(
+      {
+        prompt: this.messageText,
+        changeNumber: this.changeNum,
+        patchsetNumber: this.comment?.patch_set,
+        filePath: this.comment.path,
+        range: this.comment.range,
+        lineNumber: this.comment.line,
+      }
+    );
+    // TODO(milutin): The suggestionResponse can contain multiple suggestion
+    // options. We pick the first one for now. In future we shouldn't ignore
+    // other suggestions.
+    this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_RESPONSE, {
+      uuid: this.generatedReplacementId,
+      response: suggestionResponse.responseCode,
+      numSuggestions: suggestionResponse.suggestions.length,
+      hasNewRange: suggestionResponse.suggestions?.[0]?.newRange !== undefined,
+    });
+    const suggestion = suggestionResponse.suggestions?.[0];
+    if (!suggestion) return;
+    this.generatedSuggestion = suggestion;
+  }
+
   private renderRobotActions() {
     if (!this.account || !isRobot(this.comment)) return;
     const endpoint = html`
@@ -926,13 +1110,6 @@
     if (this.permanentEditingMode) {
       this.edit();
     }
-    if (
-      isDraft(this.comment) &&
-      isNew(this.comment) &&
-      !isSaving(this.comment)
-    ) {
-      this.edit();
-    }
     if (isDraft(this.comment)) {
       this.collapsed = false;
     } else {
@@ -943,9 +1120,31 @@
   override updated(changed: PropertyValues) {
     if (changed.has('editing')) {
       if (this.editing && !this.permanentEditingMode) {
+        // Note that this is a bit fragile, because we are relying on the
+        // comment to become visible soonish. If that does not happen, then we
+        // will be waiting indefinitely and grab focus at some point in the
+        // distant future.
         whenVisible(this, () => this.textarea?.putCursorAtEnd());
       }
     }
+    if (
+      changed.has('changeNum') ||
+      changed.has('comment') ||
+      changed.has('generatedReplacement')
+    ) {
+      if (
+        !this.changeNum ||
+        !this.comment ||
+        (!hasUserSuggestion(this.comment) && !this.generatedSuggestion)
+      )
+        return;
+      (async () => {
+        this.commentedText = await this.commentModel.getCommentedCode(
+          this.comment,
+          this.changeNum
+        );
+      })();
+    }
   }
 
   override willUpdate(changed: PropertyValues) {
@@ -954,6 +1153,11 @@
       if (isDraft(this.comment) && isError(this.comment)) {
         this.edit();
       }
+      if (this.comment) {
+        this.commentModel.updateState({
+          comment: this.comment,
+        });
+      }
     }
     if (changed.has('editing')) {
       this.onEditingChanged();
@@ -983,7 +1187,7 @@
   }
 
   /** Enter editing mode. */
-  private edit() {
+  edit() {
     assert(isDraft(this.comment), 'only drafts are editable');
     if (this.editing) return;
     this.editing = true;
@@ -1008,12 +1212,15 @@
     if (hasUserSuggestion(this.comment) || replacement) {
       replacement = replacement ?? getUserSuggestion(this.comment);
       assert(!!replacement, 'malformed user suggestion');
-      const line = await this.getCommentedCode();
+      let commentedCode = this.commentedText;
+      if (!commentedCode) {
+        commentedCode = await this.getCommentedCode();
+      }
 
       return {
         fixSuggestions: createUserFixSuggestion(
           this.comment,
-          line,
+          commentedCode,
           replacement
         ),
         patchNum: this.comment.patch_set,
@@ -1067,6 +1274,8 @@
   }
 
   override focus() {
+    // Note that this may not work as intended, because the textarea is not
+    // rendered yet.
     this.textarea?.focus();
   }
 
@@ -1124,14 +1333,16 @@
   async createSuggestEdit(e: MouseEvent) {
     e.stopPropagation();
     const line = await this.getCommentedCode();
-    this.messageText += `${USER_SUGGESTION_START_PATTERN}${line}${'\n```'}`;
+    const addNewLine = this.messageText.length !== 0;
+    this.messageText += `${
+      addNewLine ? '\n' : ''
+    }${USER_SUGGESTION_START_PATTERN}${line}${'\n```'}`;
   }
 
+  // TODO(milutin): Remove once feature flag is rollout and use only model
   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!,
@@ -1158,6 +1369,7 @@
   async autoSave() {
     if (isSaving(this.comment) || this.autoSaving) return;
     if (!this.editing || !this.comment) return;
+    if (this.disableAutoSaving) return;
     assert(isDraft(this.comment), 'only drafts are editable');
     const messageToSave = this.messageText.trimEnd();
     if (messageToSave === '') return;
@@ -1176,6 +1388,15 @@
     await this.save();
   }
 
+  convertToCommentInput(): CommentInput | undefined {
+    if (!this.somethingToSave() || !this.comment) return;
+    return convertToCommentInput({
+      ...this.comment,
+      message: this.messageText.trimEnd(),
+      unresolved: this.unresolved,
+    });
+  }
+
   async save() {
     assert(isDraft(this.comment), 'only drafts are editable');
     // There is a minimal chance of `isSaving()` being false between iterations
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 59485e2..a01d3a4 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
@@ -313,11 +313,17 @@
                 <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 class="leftActions">
+                    <div class="action resolve">
+                      <label>
+                        <input
+                          checked=""
+                          id="resolvedCheckbox"
+                          type="checkbox"
+                        />
+                        Resolved
+                      </label>
+                    </div>
                   </div>
                   <div class="rightActions">
                     <gr-button
@@ -378,6 +384,17 @@
                   </gr-tooltip-content>
                 </div>
                 <div class="headerMiddle"></div>
+                <gr-button
+                  aria-disabled="false"
+                  class="action suggestEdit"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                  title="This button copies the text to make a suggestion"
+                >
+                  <gr-icon filled="" icon="edit" id="icon"> </gr-icon>
+                  Suggest edit
+                </gr-button>
                 <span class="patchset-text">Patchset 1</span>
                 <span class="separator"></span>
                 <span class="date" tabindex="0">
@@ -402,11 +419,17 @@
                 </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 class="leftActions">
+                    <div class="action resolve">
+                      <label>
+                        <input
+                          checked=""
+                          id="resolvedCheckbox"
+                          type="checkbox"
+                        />
+                        Resolved
+                      </label>
+                    </div>
                   </div>
                   <div class="rightActions">
                     <gr-button
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 5b26ede..f71c390 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
@@ -6,6 +6,7 @@
 import '@polymer/iron-input/iron-input';
 import '../gr-button/gr-button';
 import '../gr-icon/gr-icon';
+import '../gr-tooltip-content/gr-tooltip-content';
 import {
   assertIsDefined,
   copyToClipbard,
@@ -17,6 +18,10 @@
 import {customElement, property, query} from 'lit/decorators.js';
 import {GrButton} from '../gr-button/gr-button';
 import {GrIcon} from '../gr-icon/gr-icon';
+import {getAppContext} from '../../../services/app-context';
+import {Timing} from '../../../constants/reporting';
+import {when} from 'lit/directives/when.js';
+import {formStyles} from '../../../styles/form-styles';
 
 const COPY_TIMEOUT_MS = 1000;
 
@@ -39,21 +44,45 @@
   @property({type: Boolean})
   hideInput = false;
 
+  @property({type: String})
+  label?: string;
+
+  @property({type: String})
+  shortcut?: string;
+
   // Optional property for toast to announce correct name of target that was copied
   @property({type: String, reflect: true})
   copyTargetName?: string;
 
+  @property({type: Boolean})
+  multiline = false;
+
   @query('#icon')
   iconEl!: GrIcon;
 
+  private readonly reporting = getAppContext().reportingService;
+
   static override get styles() {
     return [
+      formStyles,
       css`
         .text {
           align-items: center;
           display: flex;
           flex-wrap: wrap;
         }
+        :host([nowrap]) .text {
+          flex-wrap: nowrap;
+        }
+        .text label {
+          flex: 0 0 120px;
+          color: var(--deemphasized-text-color);
+        }
+        .text .shortcut {
+          width: 27px;
+          margin: 0 var(--spacing-m);
+          color: var(--deemphasized-text-color);
+        }
         .copyText {
           flex-grow: 1;
           margin-right: var(--spacing-s);
@@ -87,22 +116,44 @@
   override render() {
     return html`
       <div class="text">
+        ${when(
+          this.label,
+          () => html`<label for="input">${this.label}</label>`
+        )}
         <iron-input
           class="copyText"
           @click=${this._handleInputClick}
           .bindValue=${this.text ?? ''}
+          part="text-container-wrapper-style"
         >
-          <input
-            id="input"
-            is="iron-input"
-            class=${classMap({hideInput: this.hideInput})}
-            type="text"
-            @click=${this._handleInputClick}
-            readonly=""
-            .value=${this.text ?? ''}
-            part="text-container-style"
-          />
+          ${when(
+            this.multiline,
+            () => html`<textarea
+              id="input"
+              is="iron-input"
+              class=${classMap({hideInput: this.hideInput})}
+              @click=${this._handleInputClick}
+              readonly=""
+              .value=${this.text ?? ''}
+              part="text-container-style"
+            >
+            </textarea>`,
+            () => html`<input
+              id="input"
+              is="iron-input"
+              class=${classMap({hideInput: this.hideInput})}
+              type="text"
+              @click=${this._handleInputClick}
+              readonly=""
+              .value=${this.text ?? ''}
+              part="text-container-style"
+            />`
+          )}
         </iron-input>
+        ${when(
+          this.shortcut,
+          () => html`<span class="shortcut">${this.shortcut}</span>`
+        )}
         <gr-tooltip-content
           ?has-tooltip=${this.hasTooltip}
           title=${ifDefined(this.buttonTitle)}
@@ -141,7 +192,12 @@
     this.text = queryAndAssert<HTMLInputElement>(this, '#input').value;
     assertIsDefined(this.text, 'text');
     this.iconEl.icon = 'check';
-    copyToClipbard(this.text, this.copyTargetName ?? 'Link');
+    this.reporting.time(Timing.COPY_TO_CLIPBOARD);
+    copyToClipbard(this.text, this.copyTargetName ?? 'Link').finally(() => {
+      this.reporting.timeEnd(Timing.COPY_TO_CLIPBOARD, {
+        copyTargetName: this.copyTargetName,
+      });
+    });
     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 4c36a56..ef01dad 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
@@ -30,7 +30,7 @@
       element,
       /* HTML */ `
         <div class="text">
-          <iron-input class="copyText">
+          <iron-input class="copyText" part="text-container-wrapper-style">
             <input
               id="input"
               is="iron-input"
@@ -60,6 +60,46 @@
     );
   });
 
+  test('render with label and shortcut', async () => {
+    element.label = 'Label';
+    element.shortcut = 'l - l';
+    await element.updateComplete;
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <div class="text">
+          <label for="input">Label</label>
+          <iron-input class="copyText" part="text-container-wrapper-style">
+            <input
+              id="input"
+              is="iron-input"
+              part="text-container-style"
+              readonly=""
+              type="text"
+            />
+          </iron-input>
+          <span class="shortcut">l - l</span>
+          <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', () => {
     queryAndAssert<GrButton>(element, '.copyToClipboard').click();
     assert.isTrue(clipboardSpy.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 019bec1..74ba635 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
@@ -9,7 +9,7 @@
 import '../gr-select/gr-select';
 import {DiffPreferencesInfo, IgnoreWhitespaceType} from '../../../types/diff';
 import {subscribe} from '../../lit/subscription-controller';
-import {formStyles} from '../../../styles/gr-form-styles';
+import {grFormStyles} from '../../../styles/gr-form-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, html} from 'lit';
 import {customElement, query, state} from 'lit/decorators.js';
@@ -68,7 +68,7 @@
   }
 
   static override get styles() {
-    return [sharedStyles, formStyles];
+    return [sharedStyles, grFormStyles];
   }
 
   override render() {
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 fb71983..ff14d26 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
@@ -116,6 +116,7 @@
         }
         .bottomContent {
           color: var(--deemphasized-text-color);
+          white-space: pre-wrap;
         }
         .bottomContent,
         .topContent {
@@ -143,6 +144,7 @@
         iron-dropdown {
           max-width: none;
           pointer-events: none;
+          z-index: 120;
         }
         paper-listbox {
           pointer-events: auto;
@@ -171,7 +173,7 @@
         }
         @media only screen and (max-width: 50em) {
           gr-select {
-            display: var(--gr-select-style-display, inline);
+            display: var(--gr-select-style-display, inline-block);
             width: var(--gr-select-style-width);
           }
           gr-button,
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 fbcd893..f7cdd7d 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -327,7 +327,6 @@
   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.
       if (this.cursor.target !== null) {
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 3110a96..7dee49f 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
@@ -32,6 +32,7 @@
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
 import {resolve} from '../../../models/dependency';
+import {formStyles} from '../../../styles/form-styles';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
@@ -109,6 +110,7 @@
   static override get styles() {
     return [
       sharedStyles,
+      formStyles,
       fontStyles,
       css`
         :host {
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 f7f9384..b45edc3 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
@@ -19,12 +19,11 @@
 import {linkifyUrlsAndApplyRewrite} from '../../../utils/link-util';
 import '../gr-account-chip/gr-account-chip';
 import '../gr-user-suggestion-fix/gr-user-suggestion-fix';
-import {KnownExperimentId} from '../../../services/flags/flags';
-import {getAppContext} from '../../../services/app-context';
 import {
   getUserSuggestionFromString,
   USER_SUGGESTION_INFO_STRING,
 } from '../../../utils/comment-util';
+import {sameOrigin} from '../../../utils/url-util';
 
 /**
  * This element optionally renders markdown and also applies some regex
@@ -41,8 +40,6 @@
   @state()
   private repoCommentLinks: CommentLinks = {};
 
-  private readonly flagsService = getAppContext().flagsService;
-
   private readonly getConfigModel = resolve(this, configModelToken);
 
   // Private const but used in tests.
@@ -55,67 +52,70 @@
    * 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
+  static override get styles() {
+    return [
+      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 {
-        /* code overrides white-space to pre, everything else should wrap as
+        :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 {
+          /* code overrides white-space to pre, everything else should wrap as
            normal. */
-        white-space: normal;
-        /* prose will automatically wrap but inline <code> blocks won't and we
+          white-space: normal;
+          /* 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;
-      }
-    `,
-  ];
+          overflow-x: auto;
+        }
+      `,
+    ];
+  }
 
   constructor() {
     super();
@@ -126,7 +126,7 @@
         this.repoCommentLinks = repoCommentLinks;
         // Always linkify URLs starting with https?://
         this.repoCommentLinks['ALWAYS_LINK_HTTP'] = {
-          match: '(https?://((?!&(gt|lt|amp|quot|apos);)\\S)+[\\w/~-])',
+          match: '(https?://((?!&(gt|lt|quot|apos);)\\S)+[\\w/~-])',
           link: '$1',
           enabled: true,
         };
@@ -154,10 +154,6 @@
   }
 
   private renderAsMarkdown() {
-    // Need to find out here, since customRender is not arrow function
-    const suggestEditsEnable = this.flagsService.isEnabled(
-      KnownExperimentId.SUGGEST_EDIT
-    );
     // Bind `this` via closure.
     const boundRewriteText = (text: string) => {
       const nonAsteriskRewrites = Object.fromEntries(
@@ -198,22 +194,31 @@
     // 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.
+    // 6. Relative links without "/" prefix are assumed to be absolute links.
     function customRenderer(renderer: {[type: string]: Function}) {
-      renderer['link'] = (href: string, title: string, text: string) =>
+      renderer['link'] = (href: string, title: string, text: string) => {
+        if (
+          !href.startsWith('https://') &&
+          !href.startsWith('mailto:') &&
+          !href.startsWith('http://') &&
+          !href.startsWith('/')
+        ) {
+          href = `https://${href}`;
+        }
         /* HTML */
-        `<a
+        return `<a
           href="${href}"
-          target="_blank"
+          ${sameOrigin(href) ? '' : 'target="_blank" rel="noopener noreferrer"'}
           ${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, infostring: string) => {
-        if (suggestEditsEnable && infostring === USER_SUGGESTION_INFO_STRING) {
+        if (infostring === USER_SUGGESTION_INFO_STRING) {
           // default santizer in markedjs is very restrictive, we need to use
           // existing html element to mark element. We cannot use css class for
           // it. Therefore we pick mark - as not frequently used html element to
@@ -272,9 +277,7 @@
   override updated() {
     // Look for @mentions and replace them with an account-label chip.
     this.convertEmailsToAccountChips();
-    if (this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
-      this.convertCodeToSuggestions();
-    }
+    this.convertCodeToSuggestions();
   }
 
   private convertEmailsToAccountChips() {
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 206082b..c1b38d5 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
@@ -14,11 +14,15 @@
 import {getAppContext} from '../../../services/app-context';
 import './gr-formatted-text';
 import {GrFormattedText} from './gr-formatted-text';
-import {createConfig} from '../../../test/test-data-generators';
+import {createComment, createConfig} from '../../../test/test-data-generators';
 import {queryAndAssert, waitUntilObserved} from '../../../test/test-utils';
 import {CommentLinks, EmailAddress} from '../../../api/rest-api';
 import {testResolver} from '../../../test/common-test-setup';
 import {GrAccountChip} from '../gr-account-chip/gr-account-chip';
+import {
+  CommentModel,
+  commentModelToken,
+} from '../gr-comment-model/gr-comment-model';
 
 suite('gr-formatted-text tests', () => {
   let element: GrFormattedText;
@@ -37,6 +41,10 @@
       testResolver(changeModelToken),
       getAppContext().restApiService
     );
+    const commentModel = new CommentModel(getAppContext().restApiService);
+    commentModel.updateState({
+      comment: createComment(),
+    });
     await setCommentLinks({
       customLinkRewrite: {
         match: '(LinkRewriteMe)',
@@ -54,9 +62,13 @@
     element = (
       await fixture(
         wrapInProvider(
-          html`<gr-formatted-text></gr-formatted-text>`,
-          configModelToken,
-          configModel
+          wrapInProvider(
+            html`<gr-formatted-text></gr-formatted-text>`,
+            configModelToken,
+            configModel
+          ),
+          commentModelToken,
+          commentModel
         )
       )
     ).querySelector('gr-formatted-text')!;
@@ -78,7 +90,7 @@
           <pre class="plaintext">
             <a
               href="http://google.com/LinkRewriteMe"
-              rel="noopener"
+              rel="noopener noreferrer"
               target="_blank"
             >
             http://google.com/LinkRewriteMe
@@ -108,7 +120,7 @@
         element,
         /* HTML */ `
           <pre class="plaintext">
-          FOO<a href="a.b.c" rel="noopener" target="_blank">foo</a>
+          FOO<a href="a.b.c" rel="noopener noreferrer" target="_blank">foo</a>
         </pre>
         `
       );
@@ -137,10 +149,10 @@
         /* HTML */ `
           <pre class="plaintext">
             Start:
-            <a href="bug/123" rel="noopener" target="_blank">
+            <a href="bug/123" rel="noopener noreferrer" target="_blank">
               bug/123
             </a>
-            <a href="bug/456" rel="noopener" target="_blank">
+            <a href="bug/456" rel="noopener noreferrer" target="_blank">
               bug/456
             </a>
           </pre>
@@ -162,7 +174,7 @@
           text with plain link:
           <a
             href="http://google.com"
-            rel="noopener"
+            rel="noopener noreferrer"
             target="_blank"
           >
             http://google.com
@@ -170,7 +182,7 @@
           text with config link:
             <a
               href="http://google.com/LinkRewriteMe"
-              rel="noopener"
+              rel="noopener noreferrer"
               target="_blank"
             >
               LinkRewriteMe
@@ -178,7 +190,7 @@
             text with complex link: A
             <a
               href="http://localhost/page?id=12"
-              rel="noopener"
+              rel="noopener noreferrer"
               target="_blank"
             >
               Link 12
@@ -223,6 +235,11 @@
       await checkLinking('https://www.google.com/');
       await checkLinking('https://www.google.com/asdf~');
       await checkLinking('https://www.google.com/asdf-');
+      await checkLinking('https://www.google.com/asdf-');
+      // matches & part as well, even we first linkify and then htmlEscape
+      await checkLinking(
+        'https://google.com/traces/list?project=gerrit&tid=123'
+      );
     });
   });
 
@@ -247,7 +264,11 @@
               <p>text</p>
               <p>
                 text with plain link:
-                <a href="http://google.com" rel="noopener" target="_blank">
+                <a
+                  href="http://google.com"
+                  rel="noopener noreferrer"
+                  target="_blank"
+                >
                   http://google.com
                 </a>
               </p>
@@ -255,7 +276,7 @@
                 text with config link:
                 <a
                   href="http://google.com/LinkRewriteMe"
-                  rel="noopener"
+                  rel="noopener noreferrer"
                   target="_blank"
                 >
                   LinkRewriteMe
@@ -266,7 +287,7 @@
                 text with complex link: A
                 <a
                   href="http://localhost/page?id=12"
-                  rel="noopener"
+                  rel="noopener noreferrer"
                   target="_blank"
                 >
                   Link 12
@@ -295,7 +316,7 @@
         text with plain link:
         <a
           href="http://google.com"
-          rel="noopener"
+          rel="noopener noreferrer"
           target="_blank"
         >
           http://google.com
@@ -303,7 +324,7 @@
         text with config link:
           <a
             href="http://google.com/LinkRewriteMe"
-            rel="noopener"
+            rel="noopener noreferrer"
             target="_blank"
           >
             LinkRewriteMe
@@ -312,7 +333,7 @@
         text with complex link: A
           <a
             href="http://localhost/page?id=12"
-            rel="noopener"
+            rel="noopener noreferrer"
             target="_blank"
           >
             Link 12
@@ -346,7 +367,11 @@
               <h6>h6-heading</h6>
               <h1>
                 heading with plain link:
-                <a href="http://google.com" rel="noopener" target="_blank">
+                <a
+                  href="http://google.com"
+                  rel="noopener noreferrer"
+                  target="_blank"
+                >
                   http://google.com
                 </a>
               </h1>
@@ -354,7 +379,7 @@
                 heading with config link:
                 <a
                   href="http://google.com/LinkRewriteMe"
-                  rel="noopener"
+                  rel="noopener noreferrer"
                   target="_blank"
                 >
                   LinkRewriteMe
@@ -473,7 +498,7 @@
                 <code>@</code>
                 <a
                   href="mailto:someone@google.com"
-                  rel="noopener"
+                  rel="noopener noreferrer"
                   target="_blank"
                 >
                   someone@google.com
@@ -486,7 +511,14 @@
     });
 
     test('renders inline links into <a> tags', async () => {
-      element.content = '[myLink](https://www.google.com)';
+      const origin = window.location.origin;
+      element.content = `[myLink1](https://www.google.com)
+        [myLink2](/destiny)
+        [myLink3](${origin}/destiny)
+        [myLink4](google.com)
+        [myLink5](http://google.com)
+        [myLink6](mailto:google@google.com)
+      `;
       await element.updateComplete;
 
       assert.shadowDom.equal(
@@ -495,8 +527,36 @@
           <marked-element>
             <div slot="markdown-html" class="markdown-html">
               <p>
-                <a href="https://www.google.com" rel="noopener" target="_blank"
-                  >myLink</a
+                <a
+                  href="https://www.google.com"
+                  rel="noopener noreferrer"
+                  target="_blank"
+                  >myLink1</a
+                >
+                <br />
+                <a href="/destiny">myLink2</a>
+                <br />
+                <a href="${origin}/destiny">myLink3</a>
+                <br />
+                <a
+                  href="https://google.com"
+                  rel="noopener noreferrer"
+                  target="_blank"
+                  >myLink4</a
+                >
+                <br />
+                <a
+                  href="http://google.com"
+                  rel="noopener noreferrer"
+                  target="_blank"
+                  >myLink5</a
+                >
+                <br />
+                <a
+                  href="mailto:google@google.com"
+                  rel="noopener noreferrer"
+                  target="_blank"
+                  >myLink6</a
                 >
               </p>
             </div>
@@ -522,7 +582,11 @@
               <blockquote>
                 <p>
                   block quote with plain link:
-                  <a href="http://google.com" rel="noopener" target="_blank">
+                  <a
+                    href="http://google.com"
+                    rel="noopener noreferrer"
+                    target="_blank"
+                  >
                     http://google.com
                   </a>
                 </p>
@@ -532,7 +596,7 @@
                   block quote with config link:
                   <a
                     href="http://google.com/LinkRewriteMe"
-                    rel="noopener"
+                    rel="noopener noreferrer"
                     target="_blank"
                   >
                     LinkRewriteMe
@@ -572,7 +636,10 @@
                 <p>block quote ${escapedDiv}</p>
               </blockquote>
               <p>
-                <a href="http://google.com" rel="noopener" target="_blank"
+                <a
+                  href="http://google.com"
+                  rel="noopener noreferrer"
+                  target="_blank"
                   >inline link ${escapedDiv}</a
                 >
               </p>
@@ -622,7 +689,10 @@
             <div slot="markdown-html" class="markdown-html">
               <p>
                 I think
-                <a href="http://google.com" rel="noopener" target="_blank"
+                <a
+                  href="http://google.com"
+                  rel="noopener noreferrer"
+                  target="_blank"
                   >asterisks * rule</a
                 >
               </p>
@@ -645,6 +715,10 @@
       await checkLinking('http://www.google.com');
       await checkLinking('https://www.google.com');
       await checkLinking('https://www.google.com/');
+      // matches & part as well, even we first linkify and then htmlEscape
+      await checkLinking(
+        'https://google.com/traces/list?project=gerrit&tid=123'
+      );
     });
 
     suite('user suggest fix', () => {
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
index 394015e..01e8a87 100644
--- 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
@@ -42,9 +42,13 @@
 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 {
+  DashboardType,
+  createDashboardUrl,
+} from '../../../models/views/dashboard';
 import {fire, fireReload} from '../../../utils/event-util';
 import {userModelToken} from '../../../models/user/user-model';
+import {getDocUrl} from '../../../utils/url-util';
 
 @customElement('gr-hovercard-account-contents')
 export class GrHovercardAccountContents extends LitElement {
@@ -73,6 +77,8 @@
   @state()
   serverConfig?: ServerInfo;
 
+  @state() private docsBaseUrl = '';
+
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly reporting = getAppContext().reportingService;
@@ -95,6 +101,11 @@
         this.serverConfig = config;
       }
     );
+    subscribe(
+      this,
+      () => this.getConfigModel().docsBaseUrl$,
+      docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
+    );
   }
 
   static override get styles() {
@@ -105,6 +116,7 @@
         .top,
         .attention,
         .status,
+        .displayName,
         .voteable {
           padding: var(--spacing-s) var(--spacing-l);
         }
@@ -181,7 +193,8 @@
         </div>
       </div>
       ${this.renderAccountStatusPlugins()} ${this.renderAccountStatus()}
-      ${this.renderLinks()} ${this.renderChangeRelatedInfoAndActions()}
+      ${this.renderDisplayName()} ${this.renderLinks()}
+      ${this.renderChangeRelatedInfoAndActions()}
     `;
   }
 
@@ -255,9 +268,8 @@
         @enter=${() => {
           fire(this, 'link-clicked', {});
         }}
+        >Changes</a
       >
-        Changes
-      </a>
       ·
       <a
         href=${ifDefined(this.computeOwnerDashboardLink())}
@@ -267,9 +279,8 @@
         @enter=${() => {
           fire(this, 'link-clicked', {});
         }}
+        >Dashboard</a
       >
-        Dashboard
-      </a>
     </div>`;
   }
 
@@ -283,6 +294,16 @@
     `;
   }
 
+  private renderDisplayName() {
+    if (!this.account.display_name) return nothing;
+    return html`
+      <div class="displayName">
+        <span class="title">Display name:</span>
+        <span class="value">${this.account.display_name.trim()}</span>
+      </div>
+    `;
+  }
+
   private renderNeedsAttention() {
     if (!(this.isAttentionEnabled && this.hasUserAttention)) return nothing;
     const lastUpdate = getLastUpdate(this.account, this.change);
@@ -297,8 +318,9 @@
           ></gr-icon>
           <span> ${this.computePronoun()} turn to take action. </span>
           <a
-            href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+            href=${getDocUrl(this.docsBaseUrl, 'user-attention-set.html')}
             target="_blank"
+            rel="noopener noreferrer"
           >
             <gr-icon icon="help" title="read documentation"></gr-icon>
           </a>
@@ -373,9 +395,15 @@
   computeOwnerDashboardLink() {
     if (!this.account) return undefined;
     if (this.account._account_id)
-      return createDashboardUrl({user: `${this.account._account_id}`});
+      return createDashboardUrl({
+        type: DashboardType.USER,
+        user: `${this.account._account_id}`,
+      });
     if (this.account.email)
-      return createDashboardUrl({user: this.account.email});
+      return createDashboardUrl({
+        type: DashboardType.USER,
+        user: this.account.email,
+      });
     return undefined;
   }
 
@@ -439,7 +467,7 @@
     this.restApiService
       .saveChangeReview(this.change._number, CURRENT, reviewInput)
       .then(response => {
-        if (!response || !response.ok) {
+        if (!response) {
           throw new Error(
             'something went wrong when toggling' +
               this.getReviewerState(this.change!)
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
index 7df06f4..281d295 100644
--- 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
@@ -35,6 +35,7 @@
   const ACCOUNT: AccountDetailInfo = {
     ...createAccountDetailWithId(31),
     email: 'kermit@gmail.com' as EmailAddress,
+    display_name: 'Just Kermit',
     username: 'kermit',
     name: 'Kermit The Frog',
     status: '  I am a frog  ',
@@ -76,6 +77,10 @@
           <span class="title">About me:</span>
           <span class="value">I am a frog</span>
         </div>
+        <div class="displayName">
+          <span class="title">Display name:</span>
+          <span class="value">Just Kermit</span>
+        </div>
         <div class="links">
           <gr-icon icon="link" class="linkIcon"></gr-icon>
           <a href="/q/owner:kermit@gmail.com">Changes</a>
@@ -111,6 +116,10 @@
           <span class="title"> About me: </span>
           <span class="value"> I am a frog </span>
         </div>
+        <div class="displayName">
+          <span class="title">Display name:</span>
+          <span class="value">Just Kermit</span>
+        </div>
         <div class="links">
           <gr-icon class="linkIcon" icon="link"> </gr-icon>
           <a href="/q/owner:kermit@gmail.com"> Changes </a>
@@ -229,7 +238,7 @@
     };
     await element.updateComplete;
     const saveReviewStub = stubRestApi('saveChangeReview').returns(
-      Promise.resolve({...new Response(), ok: true})
+      Promise.resolve({})
     );
     stubRestApi('removeChangeReviewer').returns(
       Promise.resolve({...new Response(), ok: true})
@@ -257,7 +266,7 @@
     };
     await element.updateComplete;
     const saveReviewStub = stubRestApi('saveChangeReview').returns(
-      Promise.resolve({...new Response(), ok: true})
+      Promise.resolve({})
     );
     stubRestApi('removeChangeReviewer').returns(
       Promise.resolve({...new Response(), ok: true})
diff --git a/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts b/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts
index a2d70bc3..7e716b6 100644
--- a/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts
@@ -49,6 +49,7 @@
           white-space: nowrap;
           word-wrap: normal;
           direction: ltr;
+          font-feature-settings: 'liga';
           -webkit-font-feature-settings: 'liga';
           -webkit-font-smoothing: antialiased;
           font-variation-settings: 'FILL' 0;
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 4b5913c..0250d82 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
@@ -3,7 +3,11 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {AnnotationPluginApi, CoverageProvider} from '../../../api/annotation';
+import {
+  AnnotationPluginApi,
+  CoverageProvider,
+  TokenHoverListener,
+} from '../../../api/annotation';
 import {PluginApi} from '../../../api/plugin';
 import {PluginsModel} from '../../../models/plugins/plugins-model';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
@@ -24,4 +28,12 @@
       provider,
     });
   }
+
+  addTokenHoverListener(listener: TokenHoverListener): void {
+    this.reporting.trackApi(this.plugin, 'annotation', 'addTokenHoverListener');
+    this.pluginsModel.tokenHoverListenerRegister({
+      pluginName: this.plugin.getPluginName(),
+      listener,
+    });
+  }
 }
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
index e3ef083e..e557ca8 100644
--- 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
@@ -10,6 +10,7 @@
 import {fixture, html, assert} from '@open-wc/testing';
 import {PluginApi} from '../../../api/plugin';
 import {
+  ActionPriority,
   ActionType,
   ChangeActionsPluginApi,
   PrimaryActionKey,
@@ -166,7 +167,11 @@
       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);
+      changeActions.setActionPriority(
+        ActionType.REVISION,
+        key1,
+        ActionPriority.PRIMARY
+      );
       await element.updateComplete;
       buttons = queryAll<GrButton>(element, '[data-action-key]');
       assert.equal(buttons[0].getAttribute('data-action-key'), key2);
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 46c759b..6b9c684 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
@@ -16,12 +16,12 @@
   JsApiService,
   EventCallback,
   ShowChangeDetail,
+  ShowDiffDetail,
   ShowRevisionActionsDetail,
 } from './gr-js-api-types';
 import {EventType, TargetElement} from '../../../api/plugin';
-import {ParsedChangeInfo} from '../../../types/types';
+import {Finalizable, 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';
 
@@ -240,6 +240,21 @@
     return review;
   }
 
+  async handleShowDiff(detail: ShowDiffDetail): Promise<void> {
+    await this.waitForPluginsToLoad();
+    for (const cb of this._getEventCallbacks(EventType.SHOW_DIFF)) {
+      try {
+        cb(detail.change, detail.patchRange, detail.fileRange);
+      } catch (err: unknown) {
+        this.reporting.error(
+          'GrJsApiInterface',
+          new Error('showDiff callback error'),
+          err
+        );
+      }
+    }
+  }
+
   _getEventCallbacks(type: EventType) {
     return eventCallbacks[type] || [];
   }
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 8e3a87d..6c180d7 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
@@ -11,10 +11,10 @@
   ReviewInput,
   RevisionInfo,
 } from '../../../types/common';
-import {Finalizable} from '../../../services/registry';
 import {EventType, TargetElement} from '../../../api/plugin';
-import {ParsedChangeInfo} from '../../../types/types';
+import {Finalizable, ParsedChangeInfo} from '../../../types/types';
 import {MenuLink} from '../../../api/admin';
+import {FileRange, PatchRange} from '../../../api/diff';
 
 export interface ShowChangeDetail {
   change?: ParsedChangeInfo;
@@ -23,6 +23,12 @@
   info: {mergeable: boolean | null};
 }
 
+export interface ShowDiffDetail {
+  change: ChangeInfo;
+  patchRange: PatchRange;
+  fileRange: FileRange;
+}
+
 export interface ShowRevisionActionsDetail {
   change: ChangeInfo;
   revisionActions: {[key: string]: ActionInfo | undefined};
@@ -52,4 +58,5 @@
   handleCommitMessage(change: ChangeInfo | ParsedChangeInfo, msg: string): void;
   canSubmitChange(change: ChangeInfo, revision?: RevisionInfo | null): boolean;
   getReviewPostRevert(change?: ChangeInfo): ReviewInput;
+  handleShowDiff(detail: ShowDiffDetail): void;
 }
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 b1b66ad..fa50c53 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
@@ -138,6 +138,8 @@
    * endpoint.
    */
   getDetails(name: string): ModuleInfo[] {
-    return this._endpoints.get(name) ?? [];
+    return (this._endpoints.get(name) ?? []).sort((m1, m2) =>
+      m1.plugin.getPluginName().localeCompare(m2.plugin.getPluginName())
+    );
   }
 }
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 a58c6cc..b78af2a 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
@@ -17,11 +17,10 @@
 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 {grFormStyles} 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';
@@ -30,6 +29,7 @@
 import {GrJsApiInterface} from './gr-js-api-interface-element';
 import {define} from '../../../models/dependency';
 import {modalStyles} from '../../../styles/gr-modal-styles';
+import {Finalizable} from '../../../types/types';
 
 enum PluginState {
   /** State that indicates the plugin is pending to be loaded. */
@@ -83,7 +83,7 @@
 export class PluginLoader implements Gerrit, Finalizable {
   public readonly styles = {
     font: fontStyles,
-    form: formStyles,
+    form: grFormStyles,
     icon: iconStyles,
     menuPage: menuPageStyles,
     spinner: spinnerStyles,
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 832b97e..d3dea78 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
@@ -37,6 +37,7 @@
 import {PluginsModel} from '../../../models/plugins/plugins-model';
 import {GrPluginStyleApi} from './gr-plugin-style-api';
 import {StylePluginApi} from '../../../api/styles';
+import {GrSuggestionsApi} from '../../plugins/gr-suggestions-api/gr-suggestions-api';
 
 const PLUGIN_NAME_NOT_SET = 'NULL';
 
@@ -214,6 +215,10 @@
     return new GrChecksApi(this.report, this.pluginsModel, this);
   }
 
+  suggestions(): GrSuggestionsApi {
+    return new GrSuggestionsApi(this.report, this.pluginsModel, this);
+  }
+
   reporting(): ReportingPluginApi {
     return new GrReportingJsApi(this.report, this);
   }
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 02f830d..f9289f4 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
@@ -66,8 +66,9 @@
   mutable = false;
 
   /**
-   * if true - show all reviewers that can vote on label
-   * if false - show only reviewers that voted on label
+   * if true - show all CC and reviewers who already voted and reviewers who can
+   * vote on label.
+   * if false - show only all CC and reviewers who already voted
    */
   @property({type: Boolean})
   showAllReviewers = true;
@@ -139,23 +140,10 @@
   override render() {
     const labelInfo = this.labelInfo;
     if (!labelInfo) return;
-    const reviewers = (this.change?.reviewers['REVIEWER'] ?? [])
-      .filter(reviewer => {
-        if (this.showAllReviewers) {
-          if (isDetailedLabelInfo(labelInfo)) {
-            return canReviewerVote(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))}
+      ${this.computeVoters(labelInfo).map(reviewer =>
+        this.renderReviewerVote(reviewer)
+      )}
     </div>`;
   }
 
@@ -221,6 +209,40 @@
   }
 
   /**
+   * if showAllReviewers = true  @return all CC and reviewers who already voted
+   * and reviewers who can vote on label
+   * Btw. if label is QuickLabelInfo we cannot provide list of reviewers who can
+   * vote on label
+   *
+   * if showAllReviewers = false @return just all CC and reviewers who already
+   * voted
+   *
+   * private but used in test
+   */
+  computeVoters(labelInfo: LabelInfo) {
+    const allReviewers = this.change?.reviewers['REVIEWER'] ?? [];
+    return allReviewers
+      .concat(this.change?.reviewers['CC'] ?? [])
+      .filter(account => {
+        if (this.showAllReviewers) {
+          if (
+            isDetailedLabelInfo(labelInfo) &&
+            allReviewers.includes(account)
+          ) {
+            return canReviewerVote(labelInfo, account);
+          } else {
+            // labelInfo is QuickLabelInfo or account is from CC
+            return hasVoted(labelInfo, account);
+          }
+        } else {
+          // !showAllReviewers
+          return hasVoted(labelInfo, account);
+        }
+      })
+      .sort((r1, r2) => sortReviewers(r1, r2, this.change, this.account));
+  }
+
+  /**
    * 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.
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 67af61f..dad056b 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
@@ -153,4 +153,103 @@
     score = '0';
     assert.equal(element._computeValueTooltip(labelInfo, score), '');
   });
+
+  suite('computeVoters', () => {
+    const account2 = createAccountWithIdNameAndEmail(7);
+    test('show reviewer who voted', () => {
+      element.change = {
+        ...createParsedChange(),
+        labels: {},
+        reviewers: {
+          REVIEWER: [account],
+          CC: [account2],
+        },
+      };
+      const approval: ApprovalInfo = {
+        value: 2,
+        _account_id: account._account_id,
+      };
+      const labelInfo = {
+        ...createDetailedLabelInfo(),
+        all: [approval],
+      };
+
+      assert.deepEqual(element.computeVoters(labelInfo), [account]);
+    });
+
+    test('show CC who voted', () => {
+      element.change = {
+        ...createParsedChange(),
+        labels: {},
+        reviewers: {
+          REVIEWER: [account2],
+          CC: [account],
+        },
+      };
+      const approval: ApprovalInfo = {
+        value: 2,
+        _account_id: account._account_id,
+      };
+      const labelInfo = {
+        ...createDetailedLabelInfo(),
+        all: [approval],
+      };
+
+      assert.deepEqual(element.computeVoters(labelInfo), [account]);
+    });
+
+    test('show all reviewers who can vote, we ignore CC who can vote', () => {
+      element.change = {
+        ...createParsedChange(),
+        labels: {},
+        reviewers: {
+          REVIEWER: [account],
+          CC: [account2],
+        },
+      };
+      element.showAllReviewers = true;
+      const approval: ApprovalInfo = {
+        value: 0,
+        _account_id: account._account_id,
+      };
+      // do not show CC who can vote
+      const approval2: ApprovalInfo = {
+        value: 0,
+        _account_id: account2._account_id,
+      };
+      const labelInfo = {
+        ...createDetailedLabelInfo(),
+        all: [approval, approval2],
+      };
+
+      assert.deepEqual(element.computeVoters(labelInfo), [account]);
+    });
+
+    test('show all reviewers who can vote and CC who voted', () => {
+      element.change = {
+        ...createParsedChange(),
+        labels: {},
+        reviewers: {
+          REVIEWER: [account],
+          CC: [account2],
+        },
+      };
+      element.showAllReviewers = true;
+      const approval: ApprovalInfo = {
+        value: 0,
+        _account_id: account._account_id,
+      };
+      // do not show CC who can vote
+      const approval2: ApprovalInfo = {
+        value: 1,
+        _account_id: account2._account_id,
+      };
+      const labelInfo = {
+        ...createDetailedLabelInfo(),
+        all: [approval, approval2],
+      };
+
+      assert.deepEqual(element.computeVoters(labelInfo), [account, account2]);
+    });
+  });
 });
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 14b5d14..53149d0 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
@@ -15,6 +15,7 @@
 import {BindValueChangeEvent} from '../../../types/events';
 import {resolve} from '../../../models/dependency';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {formStyles} from '../../../styles/form-styles';
 
 const REQUEST_DEBOUNCE_INTERVAL_MS = 200;
 
@@ -62,6 +63,7 @@
 
   static override get styles() {
     return [
+      formStyles,
       sharedStyles,
       css`
         #filter {
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 6d2fe20..175f4f7 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
@@ -22,9 +22,9 @@
 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';
+import {branchName} from '../../../utils/patch-set-util';
 
 const SUGGESTIONS_LIMIT = 15;
-const REF_PREFIX = 'refs/heads/';
 
 @customElement('gr-repo-branch-picker')
 export class GrRepoBranchPicker extends LitElement {
@@ -118,12 +118,9 @@
     if (!this.repo) {
       return Promise.resolve([]);
     }
-    if (input.startsWith(REF_PREFIX)) {
-      input = input.substring(REF_PREFIX.length);
-    }
     return this.restApiService
       .getRepoBranches(
-        input,
+        branchName(input),
         this.repo,
         SUGGESTIONS_LIMIT,
         /* offset=*/ undefined,
@@ -137,12 +134,7 @@
     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;
-        }
+        const branch = branchName(branchInfo.ref);
         return {name: branch, value: branch};
       });
   }
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 b51c4a2..354a6b6 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
@@ -82,8 +82,9 @@
   /**
    * Removes messages that describe removed reviewers, since reviewer_updates
    * are used.
+   * Private but used in tests.
    */
-  private _filterRemovedMessages() {
+  _filterRemovedMessages() {
     this.result.messages = this.result.messages.filter(
       message => message.tag !== MessageTag.TAG_DELETE_REVIEWER
     );
@@ -169,7 +170,8 @@
       newUpdates.push(batch);
     }
     (this.result
-      .reviewer_updates as unknown as ParserBatchWithNonEmptyUpdates[]) = newUpdates;
+      .reviewer_updates as unknown as ParserBatchWithNonEmptyUpdates[]) =
+      newUpdates;
     return newUpdates;
   }
 
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 4225173..b875441 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
@@ -9,8 +9,6 @@
 import {assert} from '@open-wc/testing';
 
 suite('gr-reviewer-updates-parser tests', () => {
-  let instance;
-
   test('ignores changes without messages', () => {
     const change = {};
     sinon.stub(
@@ -80,7 +78,7 @@
         },
       ],
     };
-    instance = new GrReviewerUpdatesParser(change);
+    const instance = new GrReviewerUpdatesParser(change);
     instance._filterRemovedMessages();
     assert.deepEqual(instance.result, {
       messages: [{
@@ -122,7 +120,7 @@
       ],
     };
 
-    instance = new GrReviewerUpdatesParser(change);
+    const instance = new GrReviewerUpdatesParser(change);
     instance._groupUpdates();
     change = instance.result;
 
@@ -202,7 +200,7 @@
       ],
     };
 
-    instance = new GrReviewerUpdatesParser(change);
+    const instance = new GrReviewerUpdatesParser(change);
     instance._formatUpdates();
 
     assert.equal(change.reviewer_updates.length, 2);
@@ -264,7 +262,7 @@
         message: 'Uploaded patch set 2.',
       }],
     };
-    instance = new GrReviewerUpdatesParser(change);
+    const instance = new GrReviewerUpdatesParser(change);
     instance._advanceUpdates();
     const updates = instance.result.reviewer_updates;
     assert.isBelow(parseDate(updates[0].date).getTime(), T0);
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
new file mode 100644
index 0000000..69cdedd
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
@@ -0,0 +1,259 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../embed/diff/gr-diff/gr-diff';
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {getAppContext} from '../../../services/app-context';
+import {Comment} from '../../../types/common';
+import {anyLineTooLong} from '../../../utils/diff-util';
+import {
+  DiffLayer,
+  DiffPreferencesInfo,
+  DiffViewMode,
+  RenderPreferences,
+} from '../../../api/diff';
+import {when} from 'lit/directives/when.js';
+import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
+import {resolve} from '../../../models/dependency';
+import {highlightServiceToken} from '../../../services/highlight/highlight-service';
+import {NumericChangeId} from '../../../api/rest-api';
+import {changeModelToken} from '../../../models/change/change-model';
+import {subscribe} from '../../lit/subscription-controller';
+import {FilePreview} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
+import {userModelToken} from '../../../models/user/user-model';
+import {createUserFixSuggestion} from '../../../utils/comment-util';
+import {commentModelToken} from '../gr-comment-model/gr-comment-model';
+import {fire} from '../../../utils/event-util';
+import {Interaction, Timing} from '../../../constants/reporting';
+
+declare global {
+  interface HTMLElementEventMap {
+    'add-generated-suggestion': AddGeneratedSuggestionEvent;
+  }
+}
+
+export type AddGeneratedSuggestionEvent =
+  CustomEvent<OpenUserSuggestionPreviewEventDetail>;
+export interface OpenUserSuggestionPreviewEventDetail {
+  code: string;
+}
+
+@customElement('gr-suggestion-diff-preview')
+export class GrSuggestionDiffPreview extends LitElement {
+  @property({type: String})
+  suggestion?: string;
+
+  @property({type: Boolean})
+  showAddSuggestionButton = false;
+
+  @property({type: String})
+  uuid?: string;
+
+  @state()
+  comment?: Comment;
+
+  @state()
+  commentedText?: string;
+
+  @state()
+  layers: DiffLayer[] = [];
+
+  @state()
+  previewLoadedFor?: string;
+
+  @state()
+  changeNum?: NumericChangeId;
+
+  @state()
+  preview?: FilePreview;
+
+  @state()
+  diffPrefs?: DiffPreferencesInfo;
+
+  @state()
+  renderPrefs: RenderPreferences = {
+    disable_context_control_buttons: true,
+    show_file_comment_button: false,
+    hide_line_length_indicator: true,
+  };
+
+  private readonly reporting = getAppContext().reportingService;
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly restApiService = getAppContext().restApiService;
+
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly getCommentModel = resolve(this, commentModelToken);
+
+  private readonly syntaxLayer = new GrSyntaxLayerWorker(
+    resolve(this, highlightServiceToken),
+    () => getAppContext().reportingService
+  );
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      changeNum => (this.changeNum = changeNum)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().diffPreferences$,
+      diffPreferences => {
+        if (!diffPreferences) return;
+        this.diffPrefs = diffPreferences;
+        this.syntaxLayer.setEnabled(!!this.diffPrefs.syntax_highlighting);
+      }
+    );
+    subscribe(
+      this,
+      () => this.getCommentModel().comment$,
+      comment => (this.comment = comment)
+    );
+    subscribe(
+      this,
+      () => this.getCommentModel().commentedText$,
+      commentedText => (this.commentedText = commentedText)
+    );
+  }
+
+  static override get styles() {
+    return [
+      css`
+        .buttons {
+          text-align: right;
+        }
+        code {
+          max-width: var(--gr-formatted-text-prose-max-width, none);
+          background-color: var(--background-color-secondary);
+          border: 1px solid var(--border-color);
+          border-top: 0;
+          display: block;
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-code);
+          line-height: var(--line-height-mono);
+          margin-bottom: var(--spacing-m);
+          padding: var(--spacing-xxs) var(--spacing-s);
+          overflow-x: auto;
+          /* Pre will preserve whitespace and line breaks but not wrap */
+          white-space: pre;
+          border-bottom-left-radius: var(--border-radius);
+          border-bottom-right-radius: var(--border-radius);
+        }
+      `,
+    ];
+  }
+
+  override updated(changed: PropertyValues) {
+    if (changed.has('commentedText') || changed.has('comment')) {
+      if (this.previewLoadedFor !== this.suggestion) {
+        this.fetchFixPreview();
+      }
+    }
+  }
+
+  override render() {
+    if (!this.suggestion) return nothing;
+    const code = this.suggestion;
+    return html`
+      ${when(
+        this.previewLoadedFor,
+        () => this.renderDiff(),
+        () => html`<code>${code}</code>`
+      )}
+      ${when(
+        this.showAddSuggestionButton,
+        () =>
+          html`<div class="buttons">
+            <gr-button
+              link
+              class="action add-suggestion"
+              @click=${this.handleAddGeneratedSuggestion}
+            >
+              Add suggestion to comment
+            </gr-button>
+          </div>`
+      )}
+    `;
+  }
+
+  private renderDiff() {
+    if (!this.preview) return;
+    const diff = this.preview.preview;
+    if (!anyLineTooLong(diff)) {
+      this.syntaxLayer.process(diff);
+    }
+    return html`<gr-diff
+      .prefs=${this.overridePartialDiffPrefs()}
+      .path=${this.preview.filepath}
+      .diff=${diff}
+      .layers=${this.layers}
+      .renderPrefs=${this.renderPrefs}
+      .viewMode=${DiffViewMode.UNIFIED}
+    ></gr-diff>`;
+  }
+
+  private async fetchFixPreview() {
+    if (
+      !this.changeNum ||
+      !this.comment?.patch_set ||
+      !this.suggestion ||
+      !this.commentedText
+    )
+      return;
+    const fixSuggestions = createUserFixSuggestion(
+      this.comment,
+      this.commentedText,
+      this.suggestion
+    );
+    this.reporting.time(Timing.PREVIEW_FIX_LOAD);
+    const res = await this.restApiService.getFixPreview(
+      this.changeNum,
+      this.comment?.patch_set,
+      fixSuggestions[0].replacements
+    );
+    if (!res) return;
+    const currentPreviews = Object.keys(res).map(key => {
+      return {filepath: key, preview: res[key]};
+    });
+    this.reporting.timeEnd(Timing.PREVIEW_FIX_LOAD, {
+      uuid: this.uuid,
+    });
+    if (currentPreviews.length > 0) {
+      this.preview = currentPreviews[0];
+      this.previewLoadedFor = this.suggestion;
+    }
+
+    return res;
+  }
+
+  private overridePartialDiffPrefs() {
+    if (!this.diffPrefs) return undefined;
+    return {
+      ...this.diffPrefs,
+      context: 0,
+      line_length: Math.min(this.diffPrefs.line_length, 100),
+      line_wrapping: true,
+    };
+  }
+
+  handleAddGeneratedSuggestion() {
+    if (!this.suggestion) return;
+    this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_ADDED, {
+      uuid: this.uuid,
+    });
+    fire(this, 'add-generated-suggestion', {code: this.suggestion});
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-suggestion-diff-preview': GrSuggestionDiffPreview;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts
new file mode 100644
index 0000000..86be868
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts
@@ -0,0 +1,114 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-suggestion-diff-preview';
+import {fixture, html, assert} from '@open-wc/testing';
+import {
+  CommentModel,
+  commentModelToken,
+} from '../gr-comment-model/gr-comment-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {createComment} from '../../../test/test-data-generators';
+import {getAppContext} from '../../../services/app-context';
+import {GrSuggestionDiffPreview} from './gr-suggestion-diff-preview';
+import {stubFlags} from '../../../test/test-utils';
+
+suite('gr-suggestion-diff-preview tests', () => {
+  let element: GrSuggestionDiffPreview;
+
+  setup(async () => {
+    const commentModel = new CommentModel(getAppContext().restApiService);
+    commentModel.updateState({
+      comment: createComment(),
+    });
+    element = (
+      await fixture<GrSuggestionDiffPreview>(
+        wrapInProvider(
+          html`
+            <gr-suggestion-diff-preview
+              .suggestion=${'Hello World'}
+            ></gr-suggestion-diff-preview>
+          `,
+          commentModelToken,
+          commentModel
+        )
+      )
+    ).querySelector<GrSuggestionDiffPreview>('gr-suggestion-diff-preview')!;
+    await element.updateComplete;
+  });
+
+  test('render', async () => {
+    await element.updateComplete;
+
+    assert.shadowDom.equal(element, /* HTML */ '<code>Hello World</code>');
+  });
+
+  test('render diff', async () => {
+    stubFlags('isEnabled').returns(true);
+    element.suggestion =
+      '  private handleClick(e: MouseEvent) {\ne.stopPropagation();\ne.preventDefault();';
+    element.previewLoadedFor =
+      '  private handleClick(e: MouseEvent) {\ne.stopPropagation();\ne.preventDefault();';
+    element.preview = {
+      filepath:
+        'polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip_test.ts',
+      preview: {
+        meta_a: {
+          name: 'polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip_test.ts',
+          content_type: 'application/typescript',
+          lines: 6,
+        },
+        meta_b: {
+          name: 'polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip_test.ts',
+          content_type: 'application/typescript',
+          lines: 6,
+        },
+        intraline_status: 'OK',
+        change_type: 'MODIFIED',
+        content: [
+          {
+            ab: ['export class SummaryChip {'],
+          },
+          {
+            a: [
+              '  private handleClick(event: MouseEvent) {',
+              '    event.stopPropagation();',
+              '    event.preventDefault();',
+              '  }',
+            ],
+            b: [
+              '  private handleClick(evt: MouseEvent) {',
+              '    evt.stopPropagation();',
+              '    evt.preventDefault();',
+              '  }',
+            ],
+            edit_a: [
+              [24, 2],
+              [23, 2],
+              [27, 2],
+            ],
+            edit_b: [],
+          },
+          {
+            ab: ['}'],
+          },
+        ],
+      },
+    };
+    await element.updateComplete;
+
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-diff
+          class="disable-context-control-buttons hide-line-length-indicator"
+        >
+        </gr-diff>
+      `,
+      {ignoreAttributes: ['style']}
+    );
+  });
+});
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 7779fff..1501205 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -30,6 +30,7 @@
 import {ShortcutController} from '../../lit/shortcut-controller';
 import {getAccountDisplayName} from '../../../utils/display-name-util';
 import {configModelToken} from '../../../models/config/config-model';
+import {formStyles} from '../../../styles/form-styles';
 
 const MAX_ITEMS_DROPDOWN = 10;
 
@@ -122,9 +123,11 @@
 
   private changeNum?: NumericChangeId;
 
+  // Represents the current location of the ':' or '@' that triggered a drop-down.
   // private but used in tests
   specialCharIndex = -1;
 
+  // Represents the current search string being used to query either emoji or mention suggestions.
   // private but used in tests
   currentSearchString?: string;
 
@@ -175,53 +178,56 @@
     }
   }
 
-  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;
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      formStyles,
+      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`
@@ -278,8 +284,7 @@
       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.computeIndexAndSearchString();
       this.handleTextChanged();
     }
   }
@@ -295,6 +300,8 @@
   }
 
   override focus() {
+    // Note that this may not work as intended, because the textarea is not
+    // rendered yet.
     this.textarea?.textarea.focus();
   }
 
@@ -369,6 +376,13 @@
       return;
     }
 
+    const selection = this.getVisibleDropdown().getCurrentText();
+    if (selection === '') {
+      // Nothing was selected, so treat this like a newline and reset the dropdown.
+      this.indent(e);
+      this.resetDropdown();
+      return;
+    }
     e.preventDefault();
     e.stopPropagation();
     this.setValue(this.getVisibleDropdown().getCurrentText());
@@ -386,30 +400,33 @@
       return;
     }
     const specialCharIndex = this.specialCharIndex;
+    let move = 0;
     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});
+      move = 1;
     }
     // 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.textarea!.selectionStart = specialCharIndex + text.length + move;
+    this.textarea!.selectionEnd = specialCharIndex + text.length + move;
     this.resetDropdown();
   }
 
   private addValueToText(value: string) {
     if (!this.text) return '';
-    return (
-      this.text.substr(0, this.specialCharIndex ?? 0) +
-      value +
-      this.text.substr(this.textarea!.selectionStart)
+    const specialCharIndex = this.specialCharIndex ?? 0;
+    const beforeSearchString = this.text.substring(0, specialCharIndex);
+    const afterSearchString = this.text.substring(
+      specialCharIndex + 1 + (this.currentSearchString?.length ?? 0)
     );
+    return beforeSearchString + value + afterSearchString;
   }
 
   /**
@@ -421,7 +438,7 @@
    */
   updateCaratPosition() {
     if (typeof this.textarea!.value === 'string') {
-      this.hiddenText!.textContent = this.textarea!.value.substr(
+      this.hiddenText!.textContent = this.textarea!.value.substring(
         0,
         this.textarea!.selectionStart
       );
@@ -432,12 +449,7 @@
     return caratSpan;
   }
 
-  private shouldResetDropdown(
-    text: string,
-    charIndex: number,
-    suggestions?: Item[],
-    char?: string
-  ) {
+  private shouldResetDropdown(text: string, charIndex: number, 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
@@ -448,32 +460,10 @@
         (this.currentSearchString ?? '').length + charIndex + 1 ||
       this.currentSearchString === ' ' ||
       this.currentSearchString === '\n' ||
-      !(text[charIndex] === char) ||
-      !suggestions ||
-      !suggestions.length
+      !(text[charIndex] === char)
     );
   }
 
-  // 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() {
     this.suggestions = [];
     if (this.currentSearchString === undefined) {
@@ -509,7 +499,6 @@
       this.shouldResetDropdown(
         this.text,
         this.specialCharIndex,
-        this.suggestions,
         this.text[this.specialCharIndex]
       )
     ) {
@@ -535,26 +524,20 @@
     );
   }
 
-  private computeSpecialCharIndex() {
-    const charAtCursor = this.text[this.textarea!.selectionStart - 1];
-
-    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) {
+  private computeIndexAndSearchString() {
+    const currentCarat = this.textarea?.selectionStart ?? this.text.length;
+    const m = this.text
+      .substring(0, currentCarat)
+      .match(/(?:^|\s)([:@][\S]*)$/);
+    if (!m) {
+      this.specialCharIndex = -1;
       this.currentSearchString = undefined;
       return;
     }
-    this.currentSearchString = this.text.substr(
-      this.specialCharIndex + 1,
-      this.textarea!.selectionStart - this.specialCharIndex - 1
-    );
+    this.currentSearchString = m[1].substring(1);
+    if (this.specialCharIndex !== -1) return;
+
+    this.specialCharIndex = currentCarat - m[1].length;
   }
 
   // Private but used in tests.
@@ -627,7 +610,7 @@
     this.currentSearchString = '';
     this.closeDropdown();
     this.specialCharIndex = -1;
-    this.textarea?.textarea.focus();
+    this.focus();
   }
 
   private fireChangedEvents() {
@@ -641,7 +624,7 @@
     // 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(
+    const currentLine = this.textarea!.textarea.value.substring(
       0,
       this.textarea!.selectionStart
     )
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 4dcaa80..4aef66e 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
@@ -220,20 +220,6 @@
       ]);
     });
 
-    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([
@@ -300,19 +286,19 @@
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isFalse(element.mentionsSuggestions!.isHidden);
 
-      element.text = '@h ';
+      element.text = '@h';
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isFalse(element.mentionsSuggestions!.isHidden);
 
-      element.text = '@h :';
+      element.text = '@h:';
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
       assert.isTrue(element.emojiSuggestions!.isHidden);
       assert.isFalse(element.mentionsSuggestions!.isHidden);
 
-      element.text = '@h :D';
+      element.text = '@h:D';
       await waitUntil(() => element.suggestions.length > 0);
       await element.updateComplete;
       assert.isTrue(element.emojiSuggestions!.isHidden);
@@ -347,11 +333,16 @@
       element.text = ':D@';
       await element.updateComplete;
       // emoji dropdown hidden since we have no more suggestions
-      assert.isTrue(element.emojiSuggestions!.isHidden);
+      assert.isFalse(element.emojiSuggestions!.isHidden);
       assert.isTrue(element.mentionsSuggestions!.isHidden);
 
       element.text = ':D@b';
       await element.updateComplete;
+      assert.isFalse(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);
     });
@@ -591,6 +582,42 @@
     });
     element.handleDropdownItemSelect(event);
     assert.equal(element.text, 'test test 😂');
+
+    // wait for reset dropdown to finish
+    await waitUntil(() => element.specialCharIndex === -1);
+    element.textarea!.selectionStart = 16;
+    element.textarea!.selectionEnd = 16;
+    element.text = 'test test :tears';
+    element.specialCharIndex = 10;
+    await element.updateComplete;
+    // move the cursor to the left while the suggestion popup is open
+    element.textarea!.selectionStart = 0;
+    element.handleDropdownItemSelect(event);
+    assert.equal(element.text, 'test test 😂');
+
+    // wait for reset dropdown to finish
+    await waitUntil(() => element.specialCharIndex === -1);
+    element.textarea!.selectionStart = 16;
+    element.textarea!.selectionEnd = 16;
+    const text = 'test test :tears happy';
+    // Since selectionStart is on Chrome set always on end of text, we
+    // stub it to 16
+    const stub = sinon.stub(element, 'textarea').value({
+      selectionStart: 16,
+      value: text,
+      focused: true,
+      textarea: {
+        focus: () => {},
+      },
+    });
+    element.text = text;
+    element.specialCharIndex = 10;
+    await element.updateComplete;
+    stub.restore();
+    // move the cursor to the right while the suggestion popup is open
+    element.textarea!.selectionStart = 22;
+    element.handleDropdownItemSelect(event);
+    assert.equal(element.text, 'test test 😂 happy');
   });
 
   test('updateCaratPosition', async () => {
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 681378d..c1ba729 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
@@ -70,6 +70,9 @@
             var(--tooltip-background-color);
           top: calc(-1 * var(--gr-tooltip-arrow-size));
         }
+        .text {
+          white-space: pre-wrap;
+        }
       `,
     ];
   }
@@ -82,7 +85,7 @@
         class="arrowPositionBelow arrow"
         style=${styleMap({marginLeft: this.arrowCenterOffset})}
       ></i>
-      ${this.text}
+      <div class="text">${this.text}</div>
       <i
         class="arrowPositionAbove arrow"
         style=${styleMap({marginLeft: this.arrowCenterOffset})}
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 63ed1ff..bc0cfba 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
@@ -26,7 +26,7 @@
       /* HTML */ `
         <div class="tooltip">
           <i class="arrow arrowPositionBelow" style="margin-left:0;"> </i>
-          tooltipText
+          <div class="text">tooltipText</div>
           <i class="arrow arrowPositionAbove" style="margin-left:0;"> </i>
         </div>
       `
diff --git a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
index ff068a7..6322123 100644
--- a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
+++ b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
@@ -3,11 +3,17 @@
  * Copyright 2023 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
 import {css, html, LitElement, nothing} from 'lit';
-import {customElement} from 'lit/decorators.js';
-import {getAppContext} from '../../../services/app-context';
-import {KnownExperimentId} from '../../../services/flags/flags';
+import {customElement, state} from 'lit/decorators.js';
 import {fire} from '../../../utils/event-util';
+import {getDocUrl} from '../../../utils/url-util';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
 
 declare global {
   interface HTMLElementEventMap {
@@ -22,58 +28,52 @@
 }
 
 @customElement('gr-user-suggestion-fix')
-export class GrUserSuggetionFix extends LitElement {
-  private readonly flagsService = getAppContext().flagsService;
+export class GrUserSuggestionsFix extends LitElement {
+  @state() private docsBaseUrl = '';
 
-  static override styles = [
-    css`
-      .header {
-        background-color: var(--background-color-primary);
-        border: 1px solid var(--border-color);
-        padding: var(--spacing-xs) var(--spacing-xl);
-        display: flex;
-        align-items: center;
-        border-top-left-radius: var(--border-radius);
-        border-top-right-radius: var(--border-radius);
-      }
-      .header .title {
-        flex: 1;
-      }
-      .copyButton {
-        margin-right: var(--spacing-l);
-      }
-      code {
-        max-width: var(--gr-formatted-text-prose-max-width, none);
-        background-color: var(--background-color-secondary);
-        border: 1px solid var(--border-color);
-        border-top: 0;
-        display: block;
-        font-family: var(--monospace-font-family);
-        font-size: var(--font-size-code);
-        line-height: var(--line-height-mono);
-        margin-bottom: var(--spacing-m);
-        padding: var(--spacing-xxs) var(--spacing-s);
-        overflow-x: auto;
-        /* Pre will preserve whitespace and line breaks but not wrap */
-        white-space: pre;
-        border-bottom-left-radius: var(--border-radius);
-        border-bottom-right-radius: var(--border-radius);
-      }
-    `,
-  ];
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getConfigModel().docsBaseUrl$,
+      docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
+    );
+  }
+
+  static override get styles() {
+    return [
+      css`
+        .header {
+          background-color: var(--background-color-primary);
+          border: 1px solid var(--border-color);
+          padding: var(--spacing-xs) var(--spacing-xl);
+          display: flex;
+          align-items: center;
+          border-top-left-radius: var(--border-radius);
+          border-top-right-radius: var(--border-radius);
+        }
+        .header .title {
+          flex: 1;
+        }
+        .copyButton {
+          margin-right: var(--spacing-l);
+        }
+      `,
+    ];
+  }
 
   override render() {
-    if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
-      return nothing;
-    }
     if (!this.textContent) return nothing;
     const code = this.textContent;
     return html`<div class="header">
         <div class="title">
           <span>Suggested edit</span>
           <a
-            href="https://gerrit-review.googlesource.com/Documentation/user-suggest-edits.html"
+            href=${getDocUrl(this.docsBaseUrl, 'user-suggest-edits.html')}
             target="_blank"
+            rel="noopener noreferrer"
             ><gr-icon icon="help" title="read documentation"></gr-icon
           ></a>
         </div>
@@ -81,6 +81,7 @@
           <gr-copy-clipboard
             hideInput=""
             text=${code}
+            multiline
             copyTargetName="Suggested edit"
           ></gr-copy-clipboard>
         </div>
@@ -95,7 +96,9 @@
           </gr-button>
         </div>
       </div>
-      <code>${code}</code>`;
+      <gr-suggestion-diff-preview
+        .suggestion=${this.textContent}
+      ></gr-suggestion-diff-preview>`;
   }
 
   handleShowFix() {
@@ -106,6 +109,6 @@
 
 declare global {
   interface HTMLElementTagNameMap {
-    'gr-user-suggestion-fix': GrUserSuggetionFix;
+    'gr-user-suggestion-fix': GrUserSuggestionsFix;
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts
index aecd93b..b7d73b3 100644
--- a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts
@@ -6,18 +6,32 @@
 import '../../../test/common-test-setup';
 import './gr-user-suggestion-fix';
 import {fixture, html, assert} from '@open-wc/testing';
-import {GrUserSuggetionFix} from './gr-user-suggestion-fix';
+import {GrUserSuggestionsFix} from './gr-user-suggestion-fix';
+import {
+  CommentModel,
+  commentModelToken,
+} from '../gr-comment-model/gr-comment-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+import {createComment} from '../../../test/test-data-generators';
 import {getAppContext} from '../../../services/app-context';
 
 suite('gr-user-suggestion-fix tests', () => {
-  let element: GrUserSuggetionFix;
+  let element: GrUserSuggestionsFix;
 
   setup(async () => {
-    const flagsService = getAppContext().flagsService;
-    sinon.stub(flagsService, 'isEnabled').returns(true);
-    element = await fixture<GrUserSuggetionFix>(html`
-      <gr-user-suggestion-fix>Hello World</gr-user-suggestion-fix>
-    `);
+    const commentModel = new CommentModel(getAppContext().restApiService);
+    commentModel.updateState({
+      comment: createComment(),
+    });
+    element = (
+      await fixture<GrUserSuggestionsFix>(
+        wrapInProvider(
+          html` <gr-user-suggestion-fix>Hello World</gr-user-suggestion-fix> `,
+          commentModelToken,
+          commentModel
+        )
+      )
+    ).querySelector<GrUserSuggestionsFix>('gr-user-suggestion-fix')!;
     await element.updateComplete;
   });
 
@@ -30,7 +44,8 @@
           <div class="title">
             <span>Suggested edit</span>
             <a
-              href="https://gerrit-review.googlesource.com/Documentation/user-suggest-edits.html"
+              href="/Documentation/user-suggest-edits.html"
+              rel="noopener noreferrer"
               target="_blank"
               ><gr-icon icon="help" title="read documentation"></gr-icon
             ></a>
@@ -38,17 +53,24 @@
           <div class="copyButton">
             <gr-copy-clipboard
               hideinput=""
+              multiline=""
               text="Hello World"
               copytargetname="Suggested edit"
             ></gr-copy-clipboard>
           </div>
           <div>
-            <gr-button class="action show-fix" secondary="" flatten=""
+            <gr-button
+              aria-disabled="false"
+              class="action show-fix"
+              secondary=""
+              role="button"
+              tabindex="0"
+              flatten=""
               >Show edit</gr-button
             >
           </div>
         </div>
-        <code>Hello World</code>`
+        <gr-suggestion-diff-preview></gr-suggestion-diff-preview>`
     );
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink.ts b/polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink.ts
index fb6372c..da1a283 100644
--- a/polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink.ts
+++ b/polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink.ts
@@ -52,7 +52,7 @@
     if (!this.info?.name) return nothing;
 
     return html`
-      <a href=${this.info.url} rel="noopener" target="_blank">
+      <a href=${this.info.url} rel="noopener noreferrer" target="_blank">
         <gr-tooltip-content
           title=${ifDefined(this.info.tooltip)}
           ?has-tooltip=${this.info.tooltip !== undefined}
diff --git a/polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink_test.ts b/polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink_test.ts
index acb309f..fb93118 100644
--- a/polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink_test.ts
@@ -24,7 +24,11 @@
     assert.shadowDom.equal(
       element,
       /* HTML */ `
-        <a href="https://www.google.com" rel="noopener" target="_blank">
+        <a
+          href="https://www.google.com"
+          rel="noopener noreferrer"
+          target="_blank"
+        >
           <gr-tooltip-content title="Open in Gitiles" has-tooltip>
             <img src="https://www.google.com/favicon.ico" />
           </gr-tooltip-content>
@@ -45,7 +49,11 @@
     assert.shadowDom.equal(
       element,
       /* HTML */ `
-        <a href="https://www.google.com" rel="noopener" target="_blank">
+        <a
+          href="https://www.google.com"
+          rel="noopener noreferrer"
+          target="_blank"
+        >
           <gr-tooltip-content title="Open in Gitiles" has-tooltip>
             <span>gitiles</span>
           </gr-tooltip-content>
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
index 79c40de..cf507e8 100644
--- 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
@@ -5,15 +5,21 @@
  */
 import '../../../elements/shared/gr-button/gr-button';
 import {html, LitElement} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
+import {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';
+import {subscribe} from '../../../elements/lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {
+  ColumnsToShow,
+  diffModelToken,
+  NO_COLUMNS,
+} from '../gr-diff-model/gr-diff-model';
 
-@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;
@@ -39,6 +45,33 @@
   @state()
   addTableWrapperForTesting = false;
 
+  @state() viewMode: DiffViewMode = DiffViewMode.SIDE_BY_SIDE;
+
+  @state() columns: ColumnsToShow = NO_COLUMNS;
+
+  @state() columnCount = 0;
+
+  private readonly getDiffModel = resolve(this, diffModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getDiffModel().viewMode$,
+      viewMode => (this.viewMode = viewMode)
+    );
+    subscribe(
+      this,
+      () => this.getDiffModel().columnsToShow$,
+      columnsToShow => (this.columns = columnsToShow)
+    );
+    subscribe(
+      this,
+      () => this.getDiffModel().columnCount$,
+      columnCount => (this.columnCount = columnCount)
+    );
+  }
+
   /**
    * The browser API for handling selection does not (yet) work for selection
    * across multiple shadow DOM elements. So we are rendering gr-diff components
@@ -63,39 +96,54 @@
         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>
-          `
+          this.columns.blame,
+          () =>
+            html`<td class=${diffClasses('blame')} data-line-number="0"></td>`
         )}
-        <td class=${diffClasses('contextLineNum')}></td>
         ${when(
-          this.isSideBySide(),
+          this.columns.leftNumber,
+          () => html`<td class=${diffClasses('contextLineNum')}></td>`
+        )}
+        ${when(
+          this.columns.leftSign,
           () => html`<td class=${diffClasses('sign')}></td>`
         )}
-        <td class=${diffClasses()}></td>
+        ${when(
+          this.columns.leftContent,
+          () => html`<td class=${diffClasses()}></td>`
+        )}
+        ${when(
+          this.columns.rightNumber,
+          () => html`<td class=${diffClasses('contextLineNum')}></td>`
+        )}
+        ${when(
+          this.columns.rightSign,
+          () => html`<td class=${diffClasses('sign')}></td>`
+        )}
+        ${when(
+          this.columns.rightContent,
+          () => html`<td class=${diffClasses()}></td>`
+        )}
       </tr>
     `;
   }
 
   private isSideBySide() {
-    return this.renderPrefs?.view_mode !== DiffViewMode.UNIFIED;
+    return this.viewMode !== 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';
+    // Span all columns, but not the blame column.
+    let colspan = this.columnCount;
+    if (this.columns.blame) colspan--;
     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>`
+          this.columns.blame,
+          () =>
+            html`<td class=${diffClasses('blame')} data-line-number="0"></td>`
         )}
         <td class=${diffClasses('dividerCell')} colspan=${colspan}>
           <gr-context-controls
@@ -125,6 +173,8 @@
   }
 }
 
+customElements.define('gr-context-controls-section', GrContextControlsSection);
+
 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
index 6a557fc..93db66e 100644
--- 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
@@ -3,7 +3,12 @@
  * Copyright 2022 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import {
+  DIProviderElement,
+  wrapInProvider,
+} from '../../../models/di-provider-element';
 import '../../../test/common-test-setup';
+import {DiffModel, diffModelToken} from '../gr-diff-model/gr-diff-model';
 import './gr-context-controls-section';
 import {GrContextControlsSection} from './gr-context-controls-section';
 import {fixture, html, assert} from '@open-wc/testing';
@@ -12,9 +17,17 @@
   let element: GrContextControlsSection;
 
   setup(async () => {
-    element = await fixture<GrContextControlsSection>(
-      html`<gr-context-controls-section></gr-context-controls-section>`
-    );
+    const diffModel = new DiffModel(document);
+    element = (
+      await fixture<DIProviderElement>(
+        wrapInProvider(
+          html`<gr-context-controls-section></gr-context-controls-section>`,
+          diffModelToken,
+          diffModel
+        )
+      )
+    ).querySelector<GrContextControlsSection>('gr-context-controls-section')!;
+
     element.addTableWrapperForTesting = true;
     await element.updateComplete;
   });
@@ -35,16 +48,13 @@
             >
               <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">
+              <td class="dividerCell gr-diff" colspan="4">
                 <gr-context-controls class="gr-diff" showconfig="both">
                 </gr-context-controls>
               </td>
@@ -56,10 +66,8 @@
             >
               <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>
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
index 4a2fee5..e889b90 100644
--- 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
@@ -21,7 +21,7 @@
 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 {property} from 'lit/decorators.js';
 import {subscribe} from '../../../elements/lit/subscription-controller';
 
 import {
@@ -82,7 +82,6 @@
   return 'both';
 }
 
-@customElement('gr-context-controls')
 export class GrContextControls extends LitElement {
   @property({type: Object}) renderPreferences?: RenderPreferences;
 
@@ -99,112 +98,123 @@
     linesToExpand: number;
   }>();
 
-  static override styles = css`
-    :host {
-      display: flex;
-      justify-content: center;
-      flex-direction: column;
-      position: relative;
-    }
+  static override get styles() {
+    return [
+      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='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='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;
-      }
-    }
+        :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);
-    }
+        .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 {
+          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);
-    }
-    paper-button:focus-visible {
-      /* paper-button sets this to 0, thus preventing focus-based styling. */
-      outline-width: 1px;
-    }
+        paper-button:hover {
+          /* same as defined in gr-button */
+          background: rgba(0, 0, 0, 0.12);
+        }
+        paper-button:focus-visible {
+          /* paper-button sets this to 0, thus preventing focus-based styling. */
+          outline-width: 1px;
+        }
 
-    .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);
-    }
+        .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);
-    }
+        .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;
-    }
-  `;
+        .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();
@@ -365,6 +375,11 @@
         });
       } else {
         fire(this, 'diff-context-expanded', {
+          numLines: this.numLines(),
+          buttonType: type,
+          expandedLines: linesToExpand,
+        });
+        fire(this, 'diff-context-expanded-internal-new', {
           contextGroup: this.group,
           groups,
           numLines: this.numLines(),
@@ -511,6 +526,8 @@
   }
 }
 
+customElements.define('gr-context-controls', GrContextControls);
+
 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
index 8e2f432..215dc88 100644
--- 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
@@ -8,9 +8,14 @@
 import './gr-context-controls';
 import {GrContextControls} from './gr-context-controls';
 
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {DiffFileMetaInfo, DiffInfo, SyntaxBlock} from '../../../api/diff';
+import {
+  DiffFileMetaInfo,
+  DiffInfo,
+  GrDiffLineType,
+  SyntaxBlock,
+} from '../../../api/diff';
 import {fixture, html, assert} from '@open-wc/testing';
 import {waitEventLoop} from '../../../test/test-utils';
 
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
deleted file mode 100644
index cc45e1e..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright 2017 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {GrDiffBuilder} from './gr-diff-builder';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {createElementDiff} from '../gr-diff/gr-diff-utils';
-import {GrDiffGroup} from '../gr-diff/gr-diff-group';
-import {html, render} from 'lit';
-
-export class GrDiffBuilderBinary extends GrDiffBuilder {
-  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;
-    return super.buildSectionElement(group);
-  }
-
-  public renderBinaryDiff() {
-    render(
-      html`
-        <tbody class="gr-diff binary-diff">
-          <tr class="gr-diff">
-            <td colspan="5" class="gr-diff">
-              <span>Difference in binary files</span>
-            </td>
-          </tr>
-        </tbody>
-      `,
-      this.outputEl
-    );
-  }
-}
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
deleted file mode 100644
index 328b577..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ /dev/null
@@ -1,574 +0,0 @@
-/**
- * @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 {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {
-  GrDiffBuilder,
-  DiffContextExpandedEventDetail,
-  isImageDiffBuilder,
-  isBinaryDiffBuilder,
-} from './gr-diff-builder';
-import {GrDiffBuilderImage} from './gr-diff-builder-image';
-import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
-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, fire} 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?: GrDiffBuilder;
-
-  /**
-   * 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;
-
-  private coverageLayerLeft = new GrCoverageLayer(Side.LEFT);
-
-  private coverageLayerRight = new GrCoverageLayer(Side.RIGHT);
-
-  private rangeLayer?: GrRangedCommentLayer;
-
-  // visible for testing
-  processor?: 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[] = [];
-
-  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();
-
-    // TODO: Just pass along the diff model here instead of setting many
-    // individual properties.
-    this.processor = new GrDiffProcessor();
-    this.processor.consumer = this;
-    this.processor.context = this.prefs.context;
-    this.processor.keyLocations = keyLocations;
-    if (this.renderPrefs?.num_lines_rendered_at_once) {
-      this.processor.asyncThreshold =
-        this.renderPrefs.num_lines_rendered_at_once;
-    }
-
-    this.clearDiffContent();
-    this.builder.addColumns(
-      this.diffElement,
-      getLineNumberCellWidth(this.prefs)
-    );
-
-    const isBinary = !!(this.isImageDiff || this.diff.binary);
-
-    fire(this.diffElement, 'render-start', {});
-    return (
-      this.processor
-        .process(this.diff.content, isBinary)
-        .then(async () => {
-          if (isImageDiffBuilder(this.builder)) {
-            this.builder.renderImageDiff();
-          } else if (isBinaryDiffBuilder(this.builder)) {
-            this.builder.renderBinaryDiff();
-          }
-          await this.untilGroupsRendered();
-          fire(this.diffElement, '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;
-        })
-    );
-  }
-
-  // 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);
-  };
-
-  // 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) {
-    if (!this.builder) return undefined;
-    return this.builder.getContentTdByLine(lineNumber, side);
-  }
-
-  getContentTdByLineEl(lineEl?: Element): Element | undefined {
-    if (!lineEl) return undefined;
-    const line = getLineNumber(lineEl);
-    if (!line) return undefined;
-    const side = getSideByLineEl(lineEl);
-    return this.getContentTdByLine(line, side);
-  }
-
-  getLineElByNumber(lineNumber: LineNumber, side?: Side) {
-    if (!this.builder) return undefined;
-    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;
-    fire(this.diffElement, 'render-start', {});
-    this.builder.replaceGroup(contextGroup, newGroups);
-    this.groups = this.groups.filter(g => g !== contextGroup);
-    this.groups.push(...newGroups);
-    this.untilGroupsRendered(newGroups).then(() => {
-      fire(this.diffElement, '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.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(): GrDiffBuilder {
-    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;
-    if (this.isImageDiff) {
-      builder = new GrDiffBuilderImage(
-        this.diff,
-        localPrefs,
-        this.diffElement,
-        this.baseImage,
-        this.revisionImage,
-        this.renderPrefs,
-        this.useNewImageDiffUi
-      );
-    } else if (this.diff.binary) {
-      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,
-      };
-      builder = new GrDiffBuilder(
-        this.diff,
-        localPrefs,
-        this.diffElement,
-        this.layersInternal,
-        this.renderPrefs
-      );
-    } else if (this.viewMode === DiffViewMode.UNIFIED) {
-      this.renderPrefs = {
-        ...this.renderPrefs,
-        view_mode: DiffViewMode.UNIFIED,
-      };
-      builder = new GrDiffBuilder(
-        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);
-  }
-}
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
deleted file mode 100644
index da2e9f1..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
+++ /dev/null
@@ -1,627 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup';
-import {
-  createConfig,
-  createEmptyDiff,
-} from '../../../test/test-data-generators';
-import './gr-diff-builder-element';
-import {stubBaseUrl, waitUntil} from '../../../test/test-utils';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {
-  DiffContent,
-  DiffLayer,
-  DiffPreferencesInfo,
-  DiffViewMode,
-  Side,
-} from '../../../api/diff';
-import {stubRestApi} from '../../../test/test-utils';
-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 {fixture, html, assert} from '@open-wc/testing';
-import {GrDiffRow} from './gr-diff-row';
-import {GrDiffBuilder} from './gr-diff-builder';
-import {querySelectorAll} from '../../../utils/dom-util';
-
-const DEFAULT_PREFS = createDefaultDiffPrefs();
-
-suite('gr-diff-builder tests', () => {
-  let element: GrDiffBuilderElement;
-  let builder: GrDiffBuilder;
-  let diffTable: HTMLTableElement;
-
-  const setBuilderPrefs = (prefs: Partial<DiffPreferencesInfo>) => {
-    builder = new GrDiffBuilder(
-      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({});
-  });
-
-  [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();
-      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();
-      assert.equal(builder.prefs.line_length, 72);
-    });
-  });
-
-  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('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 keyLocations: KeyLocations;
-    let content: DiffContent[] = [];
-
-    setup(() => {
-      element.viewMode = 'SIDE_BY_SIDE';
-      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.equal(querySelectorAll(diffTable, 'tbody')?.length, 4);
-    });
-
-    test('image', async () => {
-      element.diff = {...createEmptyDiff(), content, binary: true};
-      element.isImageDiff = true;
-      element.render(keyLocations);
-      await waitForEventOnce(diffTable, 'render-content');
-      assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 4);
-    });
-
-    test('binary', async () => {
-      element.diff = {...createEmptyDiff(), content, binary: true};
-      element.render(keyLocations);
-      await waitForEventOnce(diffTable, 'render-content');
-      assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 3);
-    });
-  });
-
-  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', async () => {
-      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');
-      let diffRows = diffTable.querySelectorAll('.diff-row');
-      // 5 lines:
-      // FILE, LOST, the changed line plus one line of context in each direction
-      assert.equal(diffRows.length, 5);
-
-      topExpandCommonButton!.click();
-
-      await waitUntil(() => {
-        diffRows = diffTable.querySelectorAll<GrDiffRow>('.diff-row');
-        return diffRows.length === 14;
-      });
-      // 14 lines: The 5 above plus the 9 unchanged lines that were expanded
-      assert.equal(diffRows.length, 14);
-      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);
-
-      await waitUntil(() => {
-        const rows = diffTable.querySelectorAll<GrDiffRow>('.diff-row');
-        return rows.length === 2 + 5 + 1 + 1 + 1;
-      });
-
-      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');
-    });
-  });
-});
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
index 1f7ffd3..84e5ffe 100644
--- 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
@@ -4,78 +4,15 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {ImageInfo} from '../../../types/common';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {RenderPreferences, Side} from '../../../api/diff';
+import {Side} from '../../../api/diff';
 import '../gr-diff-image-viewer/gr-image-viewer';
 import {html, LitElement, nothing} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators.js';
-import {GrDiffBuilder} from './gr-diff-builder';
-import {createElementDiff} from '../gr-diff/gr-diff-utils';
-import {GrDiffGroup} from '../gr-diff/gr-diff-group';
+import {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 GrDiffBuilder {
-  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);
-  }
-
-  override buildSectionElement(group: GrDiffGroup): HTMLElement {
-    const section = createElementDiff('tbody');
-    // Do not create a diff row for 'LOST'.
-    if (group.lines[0].beforeNumber !== 'FILE') return section;
-    return super.buildSectionElement(group);
-  }
-
-  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;
 
@@ -83,6 +20,8 @@
 
   @property() automaticBlink = false;
 
+  @property() columnCount = 0;
+
   /**
    * The browser API for handling selection does not (yet) work for selection
    * across multiple shadow DOM elements. So we are rendering gr-diff components
@@ -98,7 +37,7 @@
     return html`
       <tbody class="gr-diff image-diff">
         <tr class="gr-diff">
-          <td class="gr-diff" colspan="4">
+          <td class="gr-diff" colspan=${this.columnCount}>
             <gr-image-viewer
               class="gr-diff"
               .baseUrl=${imageSrc(this.baseImage)}
@@ -113,12 +52,13 @@
   }
 }
 
-@customElement('gr-diff-image-old')
 class GrDiffImageOld extends LitElement {
   @property() baseImage?: ImageInfo;
 
   @property() revisionImage?: ImageInfo;
 
+  @property() columnCount = 0;
+
   @query('img.left') baseImageEl?: HTMLImageElement;
 
   @query('img.right') revisionImageEl?: HTMLImageElement;
@@ -151,7 +91,7 @@
     return html`
       <tbody class="gr-diff endpoint">
         <tr class="gr-diff">
-          <td class="gr-diff" colspan="4">
+          <td class="gr-diff" colspan=${this.columnCount}>
             <gr-endpoint-decorator class="gr-diff" name="image-diff">
               ${this.renderEndpointParam('baseImage', this.baseImage)}
               ${this.renderEndpointParam('revisionImage', this.revisionImage)}
@@ -264,6 +204,9 @@
     : '';
 }
 
+customElements.define('gr-diff-image-new', GrDiffImageNew);
+customElements.define('gr-diff-image-old', GrDiffImageOld);
+
 declare global {
   interface HTMLElementTagNameMap {
     'gr-diff-image-new': GrDiffImageNew;
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
deleted file mode 100644
index f38ba5c..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
+++ /dev/null
@@ -1,352 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import './gr-diff-section';
-import '../gr-context-controls/gr-context-controls';
-import {
-  ContentLoadNeededEventDetail,
-  DiffContextExpandedExternalDetail,
-  DiffViewMode,
-  RenderPreferences,
-} from '../../../api/diff';
-import {LineNumber} from '../gr-diff/gr-diff-line';
-import {GrDiffGroup} from '../gr-diff/gr-diff-group';
-import {BlameInfo} from '../../../types/common';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {Side} from '../../../constants/constants';
-import {DiffLayer, isDefined} from '../../../types/types';
-import {GrDiffRow} from './gr-diff-row';
-import {GrDiffSection} from './gr-diff-section';
-import {html, render} from 'lit';
-import {diffClasses} from '../gr-diff/gr-diff-utils';
-import {when} from 'lit/directives/when.js';
-import {GrDiffBuilderImage} from './gr-diff-builder-image';
-import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
-
-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>;
-  }
-}
-
-export function isImageDiffBuilder<T extends GrDiffBuilder>(
-  x: T | GrDiffBuilderImage | undefined
-): x is GrDiffBuilderImage {
-  return !!x && !!(x as GrDiffBuilderImage).renderImageDiff;
-}
-
-export function isBinaryDiffBuilder<T extends GrDiffBuilder>(
-  x: T | GrDiffBuilderBinary | undefined
-): x is GrDiffBuilderBinary {
-  return !!x && !!(x as GrDiffBuilderBinary).renderBinaryDiff;
-}
-
-/**
- * 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.
- */
-export class GrDiffBuilder {
-  private readonly diff: DiffInfo;
-
-  readonly prefs: DiffPreferencesInfo;
-
-  renderPrefs?: RenderPreferences;
-
-  readonly outputEl: HTMLElement;
-
-  private groups: GrDiffGroup[];
-
-  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.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();
-  }
-
-  getContentTdByLine(
-    lineNumber: LineNumber,
-    side?: Side
-  ): HTMLTableCellElement | undefined {
-    if (!side) return undefined;
-    const row = this.findRow(lineNumber, side);
-    return row?.getContentCell(side);
-  }
-
-  getLineElByNumber(
-    lineNumber: LineNumber,
-    side?: Side
-  ): HTMLTableCellElement | undefined {
-    if (!side) return undefined;
-    const row = this.findRow(lineNumber, side);
-    return row?.getLineNumberCell(side);
-  }
-
-  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();
-  }
-
-  getLineNumberRows(): HTMLTableRowElement[] {
-    const rows = this.getDiffRows();
-    return rows.map(r => r.getTableRow()).filter(isDefined);
-  }
-
-  getLineNumEls(side: Side): HTMLTableCellElement[] {
-    const rows = this.getDiffRows();
-    return rows.map(r => r.getLineNumberCell(side)).filter(isDefined);
-  }
-
-  /** This is used when layers initiate an update. */
-  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 {
-    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
-    );
-  }
-
-  buildSectionElement(group: GrDiffGroup): HTMLElement {
-    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: Convert <gr-diff> to be fully lit controlled and incorporate this
-    // method into Lit's `render()` cycle.
-    const tempEl = document.createElement('div');
-    render(section, tempEl);
-    const sectionEl = tempEl.firstElementChild as GrDiffSection;
-    return sectionEl;
-  }
-
-  addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
-    const colgroup = html`
-      <colgroup>
-        <col class=${diffClasses('blame')}></col>
-        ${when(
-          this.renderPrefs?.view_mode === DiffViewMode.UNIFIED,
-          () => html` ${this.renderUnifiedColumns(lineNumberWidth)} `,
-          () => html`
-            ${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: Convert <gr-diff> to be fully lit controlled and incorporate this
-    // method into Lit's `render()` cycle.
-    const tempEl = document.createElement('div');
-    render(colgroup, tempEl);
-    const colgroupEl = tempEl.firstElementChild as HTMLElement;
-    outputEl.appendChild(colgroupEl);
-  }
-
-  private renderUnifiedColumns(lineNumberWidth: number) {
-    return html`
-      <col class=${diffClasses()} width=${lineNumberWidth}></col>
-      <col class=${diffClasses()} width=${lineNumberWidth}></col>
-      <col class=${diffClasses()}></col>
-    `;
-  }
-
-  private renderSideBySideColumns(side: Side, lineNumberWidth: number) {
-    return html`
-      <col class=${diffClasses(side)} width=${lineNumberWidth}></col>
-      <col class=${diffClasses(side, 'sign')}></col>
-      <col class=${diffClasses(side)}></col>
-    `;
-  }
-
-  /**
-   * 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);
-      }
-    }
-  }
-
-  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);
-  }
-
-  /**
-   * Set the blame information for the diff. For any already-rendered line,
-   * re-render its blame cell content.
-   */
-  setBlame(blame: BlameInfo[]) {
-    for (const blameInfo of blame) {
-      for (const range of blameInfo.ranges) {
-        for (let line = range.start; line <= range.end; line++) {
-          const row = this.findRow(line, Side.LEFT);
-          if (row) row.blameInfo = blameInfo;
-        }
-      }
-    }
-  }
-
-  /**
-   * 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
index 9acda81..a0406be 100644
--- 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
@@ -3,8 +3,8 @@
  * 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 {html, LitElement, nothing, PropertyValues} from 'lit';
+import {property, state} from 'lit/decorators.js';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {createRef, Ref, ref} from 'lit/directives/ref.js';
 import {
@@ -12,16 +12,37 @@
   Side,
   LineNumber,
   DiffLayer,
+  GrDiffLineType,
+  LOST,
+  FILE,
 } 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 {otherSide} from '../../../utils/diff-util';
 import './gr-diff-text';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {diffClasses, isResponsive} from '../gr-diff/gr-diff-utils';
+import {
+  diffClasses,
+  GrDiffCommentThread,
+  isLongCommentRange,
+  isResponsive,
+} from '../gr-diff/gr-diff-utils';
+import {resolve} from '../../../models/dependency';
+import {
+  ColumnsToShow,
+  diffModelToken,
+  NO_COLUMNS,
+} from '../gr-diff-model/gr-diff-model';
+import {when} from 'lit/directives/when.js';
+import {isDefined} from '../../../types/types';
+import {BehaviorSubject, combineLatest} from 'rxjs';
+import '../../../elements/shared/gr-hovercard/gr-hovercard';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {distinctUntilChanged, map} from 'rxjs/operators';
+import {deepEqual} from '../../../utils/deep-util';
+import {subscribe} from '../../../elements/lit/subscription-controller';
 
-@customElement('gr-diff-row')
 export class GrDiffRow extends LitElement {
   contentLeftRef: Ref<LitElement> = createRef();
 
@@ -42,19 +63,19 @@
   @property({type: Object})
   left?: GrDiffLine;
 
+  private left$ = new BehaviorSubject<GrDiffLine | undefined>(undefined);
+
   @property({type: Object})
   right?: GrDiffLine;
 
+  private right$ = new BehaviorSubject<GrDiffLine | undefined>(undefined);
+
   @property({type: Object})
   blameInfo?: BlameInfo;
 
   @property({type: Object})
   responsiveMode?: DiffResponsiveMode;
 
-  /**
-   * true: side-by-side diff
-   * false: unified diff
-   */
   @property({type: Boolean})
   unifiedDiff = false;
 
@@ -75,8 +96,13 @@
    * running such tests the render() method has to wrap the DOM in a proper
    * <table> element.
    */
-  @state()
-  addTableWrapperForTesting = false;
+  @state() addTableWrapperForTesting = false;
+
+  @state() leftComments: GrDiffCommentThread[] = [];
+
+  @state() rightComments: GrDiffCommentThread[] = [];
+
+  @state() columns: ColumnsToShow = NO_COLUMNS;
 
   /**
    * Keeps track of whether diff layers have already been applied to the diff
@@ -90,6 +116,51 @@
    */
   private layersApplied = false;
 
+  private readonly getDiffModel = resolve(this, diffModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () =>
+        combineLatest([this.left$, this.getDiffModel().comments$]).pipe(
+          map(([left, comments]) =>
+            comments.filter(
+              c =>
+                c.line === left?.lineNumber(Side.LEFT) && c.side === Side.LEFT
+            )
+          ),
+          distinctUntilChanged(deepEqual)
+        ),
+      leftComments => (this.leftComments = leftComments)
+    );
+    subscribe(
+      this,
+      () =>
+        combineLatest([this.right$, this.getDiffModel().comments$]).pipe(
+          map(([right, comments]) =>
+            comments.filter(
+              c =>
+                c.line === right?.lineNumber(Side.RIGHT) &&
+                c.side === Side.RIGHT
+            )
+          ),
+          distinctUntilChanged(deepEqual)
+        ),
+      rightComments => (this.rightComments = rightComments)
+    );
+    subscribe(
+      this,
+      () => this.getDiffModel().columnsToShow$,
+      columnsToShow => (this.columns = columnsToShow)
+    );
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('left')) this.left$.next(this.left);
+    if (changedProperties.has('right')) this.right$.next(this.right);
+  }
+
   /**
    * The browser API for handling selection does not (yet) work for selection
    * across multiple shadow DOM elements. So we are rendering gr-diff components
@@ -215,6 +286,7 @@
   }
 
   private renderBlameCell() {
+    if (!this.columns.blame) return nothing;
     // td.blame has `white-space: pre`, so prettier must not add spaces.
     // prettier-ignore
     return html`
@@ -255,12 +327,14 @@
       ></span>`;
   }
 
-  private renderLineNumberCell(side: Side): TemplateResult {
+  private renderLineNumberCell(side: Side) {
+    if (!this.columns.leftNumber && side === Side.LEFT) return nothing;
+    if (!this.columns.rightNumber && side === Side.RIGHT) return nothing;
     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' : '';
+      const blankClass = isBlank ? 'blankLineNum' : '';
       return html`<td
         ${ref(this.lineNumberRef(side))}
         class=${diffClasses(side, blankClass)}
@@ -281,8 +355,8 @@
     lineNumber: LineNumber,
     side: Side
   ) {
-    if (this.hideFileCommentButton && lineNumber === 'FILE') return;
-    if (lineNumber === 'LOST') return;
+    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`
@@ -293,23 +367,28 @@
         data-value=${lineNumber}
         aria-label=${ifDefined(
           this.computeLineNumberAriaLabel(line, lineNumber)
-        )}
+    )}
+        @click=${() => this.getDiffModel().createCommentOnLine(lineNumber, side)}
         @mouseenter=${() =>
           fire(this, 'line-mouse-enter', {lineNum: lineNumber, side})}
         @mouseleave=${() =>
           fire(this, 'line-mouse-leave', {lineNum: lineNumber, side})}
-      >${lineNumber === 'FILE' ? 'File' : lineNumber.toString()}</button>
+      >${lineNumber === FILE ? 'FILE' : lineNumber.toString()}</button>
     `;
   }
 
   private computeLineNumberAriaLabel(line: GrDiffLine, lineNumber: LineNumber) {
-    if (lineNumber === 'FILE') return 'Add file comment';
+    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 === 'LOST' || lineNumber <= 0) return undefined;
+    if (
+      lineNumber === LOST ||
+      (typeof lineNumber === 'number' && lineNumber <= 0)
+    )
+      return undefined;
 
     switch (line.type) {
       case GrDiffLineType.REMOVE:
@@ -323,9 +402,11 @@
   }
 
   private renderContentCell(side: Side) {
+    if (!this.columns.leftContent && side === Side.LEFT) return nothing;
+    if (!this.columns.rightContent && side === Side.RIGHT) return nothing;
+
     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);
@@ -336,8 +417,8 @@
     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');
+    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
@@ -345,6 +426,11 @@
       <td
         ${ref(this.contentCellRef(side))}
         class=${diffClasses(...extras)}
+        @click=${() => {
+          if (lineNumber) {
+            this.getDiffModel().selectLine(lineNumber, side);
+          }
+        }}
         @mouseenter=${() => {
           if (lineNumber)
             fire(this, 'line-mouse-enter', {lineNum: lineNumber, side});
@@ -353,12 +439,14 @@
           if (lineNumber)
             fire(this, 'line-mouse-leave', {lineNum: lineNumber, side});
         }}
-      >${this.renderText(side)}${this.renderThreadGroup(side)}</td>
+      >${this.renderText(side)}${this.renderLostMessage(side)}${this.renderThreadGroup(side)}</td>
     `;
   }
 
   private renderSignCell(side: Side) {
-    if (this.unifiedDiff) return nothing;
+    if (!this.columns.leftSign && side === Side.LEFT) return nothing;
+    if (!this.columns.rightSign && side === Side.RIGHT) return nothing;
+
     const line = this.line(side);
     assertIsDefined(line, 'line');
     const isBlank = line.type === GrDiffLineType.BLANK;
@@ -374,21 +462,53 @@
     return html`<td class=${diffClasses(...extras)}>${sign}</td>`;
   }
 
+  private renderLostMessage(side: Side) {
+    if (this.lineNumber(side) !== LOST) return nothing;
+    if (this.getComments(side).length === 0) return nothing;
+    // .content has `white-space: pre`, so prettier must not add spaces.
+    // prettier-ignore
+    return html`<div class="lost-message"
+      ><gr-icon icon="info"></gr-icon
+      ><span>Original comment position not found in this patchset</span
+    ></div>`;
+  }
+
   private renderThreadGroup(side: Side) {
-    const lineNumber = this.lineNumber(side);
-    if (!lineNumber) return nothing;
+    if (!this.lineNumber(side)) return nothing;
+
+    if (
+      this.getComments(side).length === 0 &&
+      (!this.unifiedDiff || this.getComments(otherSide(side)).length === 0)
+    ) {
+      return nothing;
+    }
     return html`<div class="thread-group" data-side=${side}>
-      <slot name="${side}-${lineNumber}"></slot>
-      ${this.renderSecondSlot()}
+      ${this.renderSlot(side)}
+      ${when(this.unifiedDiff, () => this.renderSlot(otherSide(side)))}
     </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 renderSlot(side: Side) {
+    const line = this.lineNumber(side);
+    if (!line) return nothing;
+    if (this.getComments(side).length === 0) return nothing;
+    return html`
+      ${this.renderRangedCommentHints(side)}
+      <slot name="${side}-${line}"></slot>
+    `;
+  }
+
+  private renderRangedCommentHints(side: Side) {
+    const ranges = this.getComments(side)
+      .map(c => c.range)
+      .filter(isDefined)
+      .filter(isLongCommentRange);
+    return ranges.map(
+      range =>
+        html`
+          <gr-ranged-comment-hint .range=${range}></gr-ranged-comment-hint>
+        `
+    );
   }
 
   private contentRef(side: Side) {
@@ -407,14 +527,18 @@
       : this.lineNumberRightRef;
   }
 
-  private lineNumber(side: Side) {
+  lineNumber(side: Side) {
     return this.line(side)?.lineNumber(side);
   }
 
-  private line(side: Side) {
+  line(side: Side) {
     return side === Side.LEFT ? this.left : this.right;
   }
 
+  private getComments(side: Side) {
+    return side === Side.LEFT ? this.leftComments : this.rightComments;
+  }
+
   private getType(side?: Side): string | undefined {
     if (this.unifiedDiff) return undefined;
     if (side === Side.LEFT) return this.left?.type;
@@ -437,7 +561,7 @@
   private renderText(side: Side) {
     const line = this.line(side);
     const lineNumber = this.lineNumber(side);
-    if (lineNumber === 'FILE' || lineNumber === 'LOST') return;
+    if (typeof lineNumber !== 'number') return;
 
     // Note that `this.layersApplied` will wipe away the <gr-diff-text>, and
     // another rendering cycle will be initiated in `updated()`.
@@ -445,6 +569,7 @@
     const textElement = line?.text && !this.layersApplied
       ? html`<gr-diff-text
           ${ref(this.contentRef(side))}
+          data-side=${ifDefined(side)}
           .text=${line?.text}
           .tabSize=${this.tabSize}
           .lineLimit=${this.lineLength}
@@ -467,6 +592,8 @@
   }
 }
 
+customElements.define('gr-diff-row', GrDiffRow);
+
 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
index 42d30aa..95b0357 100644
--- 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
@@ -8,7 +8,9 @@
 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';
+import {DiffViewMode, GrDiffLineType} from '../../../api/diff';
+import {diffModelToken} from '../gr-diff-model/gr-diff-model';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-diff-row test', () => {
   let element: GrDiffRow;
@@ -49,17 +51,14 @@
                   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>
+                  <gr-diff-text data-side="left"> 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">
@@ -73,17 +72,13 @@
                   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>
+                  <gr-diff-text data-side="right"> lorem ipsum </gr-diff-text>
                 </div>
               </td>
             </tr>
@@ -100,6 +95,8 @@
     line.text = 'lorem ipsum';
     element.left = line;
     element.right = line;
+    const diffModel = testResolver(diffModelToken);
+    diffModel.updateState({renderPrefs: {view_mode: DiffViewMode.UNIFIED}});
     element.unifiedDiff = true;
     await element.updateComplete;
     assert.lightDom.equal(
@@ -141,11 +138,7 @@
                   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>
+                  <gr-diff-text data-side="right"> lorem ipsum </gr-diff-text>
                 </div>
               </td>
             </tr>
@@ -177,7 +170,6 @@
             >
               <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>
@@ -192,17 +184,13 @@
                   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>
+                  <gr-diff-text data-side="right"> lorem ipsum </gr-diff-text>
                 </div>
               </td>
               <slot name="post-right-line-1"></slot>
@@ -243,21 +231,16 @@
                   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>
+                  <gr-diff-text data-side="left"> lorem ipsum </gr-diff-text>
                 </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>
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
index e5d3d2e..d249801 100644
--- 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
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {html, LitElement} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
+import {property, queryAll, state} from 'lit/decorators.js';
 import {
   DiffInfo,
   DiffLayer,
@@ -15,11 +15,7 @@
   DiffPreferencesInfo,
 } from '../../../api/diff';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {
-  countLines,
-  diffClasses,
-  getResponsiveMode,
-} from '../gr-diff/gr-diff-utils';
+import {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';
@@ -27,24 +23,39 @@
 import './gr-diff-row';
 import {when} from 'lit/directives/when.js';
 import {fire} from '../../../utils/event-util';
+import {countLines} from '../../../utils/diff-util';
+import {resolve} from '../../../models/dependency';
+import {
+  ColumnsToShow,
+  diffModelToken,
+  NO_COLUMNS,
+} from '../gr-diff-model/gr-diff-model';
+import {subscribe} from '../../../elements/lit/subscription-controller';
 
-@customElement('gr-diff-section')
 export class GrDiffSection extends LitElement {
+  @queryAll('gr-diff-row')
+  diffRows?: NodeListOf<GrDiffRow>;
+
   @property({type: Object})
   group?: GrDiffGroup;
 
-  @property({type: Object})
+  @state()
   diff?: DiffInfo;
 
-  @property({type: Object})
+  @state()
   renderPrefs?: RenderPreferences;
 
-  @property({type: Object})
+  @state()
   diffPrefs?: DiffPreferencesInfo;
 
-  @property({type: Object})
+  @state()
   layers: DiffLayer[] = [];
 
+  @state()
+  lineLength = 100;
+
+  @state() columns: ColumnsToShow = NO_COLUMNS;
+
   /**
    * 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
@@ -53,6 +64,49 @@
   @state()
   addTableWrapperForTesting = false;
 
+  @state() viewMode: DiffViewMode = DiffViewMode.SIDE_BY_SIDE;
+
+  private readonly getDiffModel = resolve(this, diffModelToken);
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getDiffModel().lineLength$,
+      lineLength => (this.lineLength = lineLength)
+    );
+    subscribe(
+      this,
+      () => this.getDiffModel().viewMode$,
+      viewMode => (this.viewMode = viewMode)
+    );
+    subscribe(
+      this,
+      () => this.getDiffModel().diff$,
+      diff => (this.diff = diff)
+    );
+    subscribe(
+      this,
+      () => this.getDiffModel().renderPrefs$,
+      renderPrefs => (this.renderPrefs = renderPrefs)
+    );
+    subscribe(
+      this,
+      () => this.getDiffModel().diffPrefs$,
+      diffPrefs => (this.diffPrefs = diffPrefs)
+    );
+    subscribe(
+      this,
+      () => this.getDiffModel().layers$,
+      layers => (this.layers = layers)
+    );
+    subscribe(
+      this,
+      () => this.getDiffModel().columnsToShow$,
+      columnsToShow => (this.columns = columnsToShow)
+    );
+  }
+
   /**
    * The browser API for handling selection does not (yet) work for selection
    * across multiple shadow DOM elements. So we are rendering gr-diff components
@@ -64,6 +118,13 @@
     return this;
   }
 
+  protected override async getUpdateComplete(): Promise<boolean> {
+    const result = await super.getUpdateComplete();
+    const rows = [...(this.diffRows ?? [])];
+    await Promise.all(rows.map(row => row.updateComplete));
+    return result;
+  }
+
   override render() {
     if (!this.group) return;
     const extras: string[] = [];
@@ -84,11 +145,11 @@
       <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)}`;
+          const leftClass = `left-${pair.left.lineNumber(Side.LEFT)}`;
+          const rightClass = `right-${pair.right.lineNumber(Side.RIGHT)}`;
           return html`
             <gr-diff-row
-              class="${leftCl} ${rightCl}"
+              class="${leftClass} ${rightClass}"
               .left=${pair.left}
               .right=${pair.right}
               .layers=${this.layers}
@@ -112,7 +173,7 @@
   }
 
   private isUnifiedDiff() {
-    return this.renderPrefs?.view_mode === DiffViewMode.UNIFIED;
+    return this.viewMode === DiffViewMode.UNIFIED;
   }
 
   getLinePairs() {
@@ -165,10 +226,6 @@
     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="move_item">
@@ -181,11 +238,30 @@
         class=${diffClasses('moveControls', movedIn ? 'movedIn' : 'movedOut')}
       >
         ${when(
-          this.isUnifiedDiff(),
-          () => html`${lineNumberCell} ${lineNumberCell} ${moveCell}`,
-          () => html`${lineNumberCell} ${signCell}
-          ${movedIn ? plainCell : moveCell} ${lineNumberCell} ${signCell}
-          ${movedIn ? moveCell : plainCell}`
+          this.columns.blame,
+          () => html`<td class=${diffClasses('blame')}></td>`
+        )}
+        ${when(
+          this.columns.leftNumber,
+          () => html`<td class=${diffClasses('moveControlsLineNumCol')}></td>`
+        )}
+        ${when(
+          this.columns.leftSign,
+          () => html`<td class=${diffClasses('sign')}></td>`
+        )}
+        ${when(this.columns.leftContent, () =>
+          movedIn ? plainCell : moveCell
+        )}
+        ${when(
+          this.columns.rightNumber,
+          () => html`<td class=${diffClasses('moveControlsLineNumCol')}></td>`
+        )}
+        ${when(
+          this.columns.rightSign,
+          () => html`<td class=${diffClasses('sign')}></td>`
+        )}
+        ${when(this.columns.rightContent, () =>
+          movedIn || this.isUnifiedDiff() ? moveCell : plainCell
         )}
       </tr>
     `;
@@ -243,6 +319,8 @@
   }
 }
 
+customElements.define('gr-diff-section', GrDiffSection);
+
 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
index 381f9b2..e85e945 100644
--- 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
@@ -11,6 +11,8 @@
 import {GrDiffLine} from '../gr-diff/gr-diff-line';
 import {DiffViewMode, GrDiffLineType} from '../../../api/diff';
 import {waitQueryAndAssert} from '../../../test/test-utils';
+import {diffModelToken} from '../gr-diff-model/gr-diff-model';
+import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-diff-section test', () => {
   let element: GrDiffSection;
@@ -49,8 +51,8 @@
           <table>
             <tbody>
               <tr class="gr-diff moveControls movedOut">
+                <td class="blame gr-diff"></td>
                 <td class="gr-diff moveControlsLineNumCol"></td>
-                <td class="gr-diff sign"></td>
                 <td class="gr-diff moveHeader">
                   <gr-range-header class="gr-diff" icon="move_item">
                     <div class="gr-diff">
@@ -62,7 +64,6 @@
                   </gr-range-header>
                 </td>
                 <td class="gr-diff moveControlsLineNumCol"></td>
-                <td class="gr-diff sign"></td>
                 <td class="gr-diff"></td>
               </tr>
             </tbody>
@@ -73,10 +74,8 @@
     });
 
     test('unified', async () => {
-      element.renderPrefs = {
-        ...element.renderPrefs,
-        view_mode: DiffViewMode.UNIFIED,
-      };
+      const diffModel = testResolver(diffModelToken);
+      diffModel.updateState({renderPrefs: {view_mode: DiffViewMode.UNIFIED}});
       const row = await waitQueryAndAssert(element, 'tr.moveControls');
       // Semantic dom diff has a problem with just comparing table rows or
       // cells directly. So as a workaround put the row into an empty test
@@ -89,6 +88,7 @@
           <table>
             <tbody>
               <tr class="gr-diff moveControls movedOut">
+                <td class="blame gr-diff"></td>
                 <td class="gr-diff moveControlsLineNumCol"></td>
                 <td class="gr-diff moveControlsLineNumCol"></td>
                 <td class="gr-diff moveHeader">
@@ -155,17 +155,13 @@
                   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>
+                  <gr-diff-text data-side="left">asdf</gr-diff-text>
                 </div>
               </td>
               <td class="gr-diff lineNum right" data-value="1">
@@ -179,17 +175,13 @@
                   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>
+                  <gr-diff-text data-side="right">asdf </gr-diff-text>
                 </div>
               </td>
             </tr>
@@ -212,17 +204,13 @@
                   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>
+                  <gr-diff-text data-side="left"> qwer</gr-diff-text>
                 </div>
               </td>
               <td class="gr-diff lineNum right" data-value="1">
@@ -236,17 +224,13 @@
                   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>
+                  <gr-diff-text data-side="right">qwer </gr-diff-text>
                 </div>
               </td>
             </tr>
@@ -269,17 +253,13 @@
                   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>
+                  <gr-diff-text data-side="left">zxcv </gr-diff-text>
                 </div>
               </td>
               <td class="gr-diff lineNum right" data-value="1">
@@ -293,17 +273,13 @@
                   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>
+                  <gr-diff-text data-side="right">zxcv </gr-diff-text>
                 </div>
               </td>
             </tr>
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
index c1b13ac..5161b18 100644
--- 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
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {LitElement, html, TemplateResult} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
+import {property} from 'lit/decorators.js';
 import {styleMap} from 'lit/directives/style-map.js';
 import {diffClasses} from '../gr-diff/gr-diff-utils';
 
@@ -25,7 +25,6 @@
  * 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
@@ -145,6 +144,8 @@
   }
 }
 
+customElements.define('gr-diff-text', GrDiffText);
+
 declare global {
   interface HTMLElementTagNameMap {
     'gr-diff-text': GrDiffText;
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
index e9076aa..a9e332a 100644
--- 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
@@ -6,16 +6,16 @@
 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 {
+  GrAnnotationImpl,
+  getStringLength,
+} 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);
 
@@ -117,18 +117,6 @@
     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;
@@ -147,8 +135,8 @@
       // 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);
+      const index = getStringLength(text.slice(0, match.index));
+      const length = getStringLength(token);
 
       atLeastOneTokenMatched = true;
       const highlightTypeClass =
@@ -158,7 +146,7 @@
       // 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(
+      GrAnnotationImpl.annotateElement(
         el,
         index,
         length,
@@ -222,7 +210,12 @@
       token: newHighlight,
       element,
     } = this.findTokenAncestor(e?.target);
-    if (!newHighlight || newHighlight === this.currentHighlight) return;
+    if (
+      !newHighlight ||
+      (this.currentHighlight === newHighlight &&
+        this.currentHighlightLineNumber === line)
+    )
+      return;
     this.hoveredElement = element;
     this.updateTokenTask = debounce(
       this.updateTokenTask,
@@ -345,7 +338,7 @@
       start_line: line,
       start_column: index + 1, // 1-based inclusive
       end_line: line,
-      end_column: index + GrAnnotation.getStringLength(token), // 1-based inclusive
+      end_column: index + 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
index 8fd03bb..8d0050f 100644
--- 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
@@ -4,10 +4,14 @@
  * 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 {
+  GrDiffLineType,
+  Side,
+  TokenHighlightEventDetails,
+} from '../../../api/diff';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
 import {HOVER_DELAY_MS, TokenHighlightLayer} from './token-highlight-layer';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {GrAnnotationImpl} from '../gr-diff-highlight/gr-annotation';
 import {html, render} from 'lit';
 import {_testOnly_allTasks} from '../../../utils/async-util';
 import {queryAndAssert} from '../../../test/test-utils';
@@ -119,10 +123,13 @@
     }
 
     test('annotate adds css token', () => {
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      const annotateElementStub = sinon.stub(
+        GrAnnotationImpl,
+        'annotateElement'
+      );
       const el = createLine('these are words');
       annotate(el);
-      assert.isTrue(annotateElementStub.calledThrice);
+      assert.equal(annotateElementStub.callCount, 3);
       assertAnnotation(annotateElementStub.args[0], {
         parent: el,
         offset: 0,
@@ -144,7 +151,10 @@
     });
 
     test('annotate adds css tokens w/ emojis', () => {
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      const annotateElementStub = sinon.stub(
+        GrAnnotationImpl,
+        'annotateElement'
+      );
       const el = createLine('these 💩 are 👨‍👩‍👧‍👦 words');
 
       annotate(el);
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
index 9e3640b..2d1acf8 100644
--- 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
@@ -8,30 +8,22 @@
 import {
   DiffViewMode,
   GrDiffCursor as GrDiffCursorApi,
+  GrDiffLineType,
   LineNumber,
   LineSelectedEventDetail,
 } 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';
 import {fire} from '../../../utils/event-util';
-
-type GrDiffRowType = GrDiffLineType | GrDiffGroupType;
+import {GrDiffRow} from '../gr-diff-builder/gr-diff-row';
 
 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.
  *
@@ -70,17 +62,13 @@
     if (this.sideInternal === side) {
       return;
     }
-    if (this.sideInternal && this.diffRow) {
-      this.fireCursorMoved(
-        'line-cursor-moved-out',
-        this.diffRow,
-        this.sideInternal
-      );
+    if (this.diffRowTR) {
+      this.fireCursorMoved('line-cursor-moved-out');
     }
     this.sideInternal = side;
     this.updateSideClass();
-    if (this.diffRow) {
-      this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
+    if (this.diffRowTR) {
+      this.fireCursorMoved('line-cursor-moved-in');
     }
   }
 
@@ -90,28 +78,30 @@
 
   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
+  set diffRowTR(diffRowTR: HTMLTableRowElement | undefined) {
+    if (this.diffRowTRInternal) {
+      this.diffRowTRInternal.classList.remove(
+        LEFT_SIDE_CLASS,
+        RIGHT_SIDE_CLASS
       );
+      this.fireCursorMoved('line-cursor-moved-out');
     }
-    this.diffRowInternal = diffRow;
+    this.diffRowTRInternal = diffRowTR;
 
     this.updateSideClass();
-    if (this.diffRow) {
-      this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
+    if (this.diffRowTR) {
+      this.fireCursorMoved('line-cursor-moved-in');
     }
   }
 
-  get diffRow(): HTMLElement | undefined {
-    return this.diffRowInternal;
+  /**
+   * This is the current target of the diff cursor.
+   */
+  get diffRowTR(): HTMLTableRowElement | undefined {
+    return this.diffRowTRInternal;
   }
 
-  private diffRowInternal?: HTMLElement;
+  private diffRowTRInternal?: HTMLTableRowElement;
 
   private diffs: GrDiffCursorable[] = [];
 
@@ -135,16 +125,16 @@
     this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE;
     this.cursorManager.focusOnMove = true;
 
-    window.addEventListener('scroll', this._boundHandleWindowScroll);
+    window.addEventListener('scroll', this.boundHandleWindowScroll);
     this.targetSubscription = this.cursorManager.target$.subscribe(target => {
-      this.diffRow = target || undefined;
+      this.diffRowTR = (target ?? undefined) as HTMLTableRowElement | undefined;
     });
   }
 
   dispose() {
     this.cursorManager.unsetCursor();
     if (this.targetSubscription) this.targetSubscription.unsubscribe();
-    window.removeEventListener('scroll', this._boundHandleWindowScroll);
+    window.removeEventListener('scroll', this.boundHandleWindowScroll);
   }
 
   // Don't remove - used by clients embedding gr-diff outside of Gerrit.
@@ -159,22 +149,22 @@
 
   moveLeft() {
     this.side = Side.LEFT;
-    if (this._isTargetBlank()) {
+    if (this.isTargetBlank()) {
       this.moveUp();
     }
   }
 
   moveRight() {
     this.side = Side.RIGHT;
-    if (this._isTargetBlank()) {
+    if (this.isTargetBlank()) {
       this.moveUp();
     }
   }
 
   moveDown() {
-    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+    if (this.getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
       return this.cursorManager.next({
-        filter: (row: Element) => this._rowHasSide(row),
+        filter: (row: Element) => this.rowHasSide(row),
       });
     } else {
       return this.cursorManager.next();
@@ -182,9 +172,9 @@
   }
 
   moveUp() {
-    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+    if (this.getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
       return this.cursorManager.previous({
-        filter: (row: Element) => this._rowHasSide(row),
+        filter: (row: Element) => this.rowHasSide(row),
       });
     } else {
       return this.cursorManager.previous();
@@ -192,9 +182,9 @@
   }
 
   moveToVisibleArea() {
-    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+    if (this.getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
       this.cursorManager.moveToVisibleArea((row: Element) =>
-        this._rowHasSide(row)
+        this.rowHasSide(row)
       );
     } else {
       this.cursorManager.moveToVisibleArea();
@@ -203,19 +193,19 @@
 
   moveToNextChunk(clipToTop?: boolean): CursorMoveResult {
     const result = this.cursorManager.next({
-      filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
+      filter: (row: HTMLElement) => this.isFirstRowOfChunk(row),
       getTargetHeight: target => fromRowToChunk(target)?.scrollHeight || 0,
       clipToTop,
     });
-    this._fixSide();
+    this.fixSide();
     return result;
   }
 
   moveToPreviousChunk(): CursorMoveResult {
     const result = this.cursorManager.previous({
-      filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
+      filter: (row: HTMLElement) => this.isFirstRowOfChunk(row),
     });
-    this._fixSide();
+    this.fixSide();
     return result;
   }
 
@@ -224,17 +214,17 @@
       return CursorMoveResult.CLIPPED;
     }
     const result = this.cursorManager.next({
-      filter: (row: HTMLElement) => this._rowHasThread(row),
+      filter: (row: HTMLElement) => this.rowHasThread(row),
     });
-    this._fixSide();
+    this.fixSide();
     return result;
   }
 
   moveToPreviousCommentThread(): CursorMoveResult {
     const result = this.cursorManager.previous({
-      filter: (row: HTMLElement) => this._rowHasThread(row),
+      filter: (row: HTMLElement) => this.rowHasThread(row),
     });
-    this._fixSide();
+    this.fixSide();
     return result;
   }
 
@@ -244,7 +234,7 @@
     path?: string,
     intentionalMove?: boolean
   ) {
-    const row = this._findRowByNumberAndFile(number, side, path);
+    const row = this.findRowByNumberAndFile(number, side, path);
     if (row) {
       this.side = side;
       this.cursorManager.setCursor(row, undefined, intentionalMove);
@@ -252,47 +242,50 @@
   }
 
   /**
-   * Get the line number element targeted by the cursor row and side.
+   * The target of the diff cursor is always a <tr> element. That is the first
+   * direct child of a <gr-diff-row> element. We typically want to retrieve
+   * the `GrDiffRow`, because it supplies methods that we can use without
+   * making further assumptions about the internal DOM structure.
    */
-  getTargetLineElement(): HTMLElement | null {
-    let lineElSelector = '.lineNum';
-
-    if (!this.diffRow) {
-      return null;
+  getTargetDiffRow(): GrDiffRow | undefined {
+    let el: HTMLElement | undefined = this.diffRowTR;
+    while (el) {
+      if (el.tagName === 'GR-DIFF-ROW') return el as GrDiffRow;
+      el = el.parentElement ?? undefined;
     }
-
-    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-      lineElSelector += this.side === Side.LEFT ? '.left' : '.right';
-    }
-
-    return this.diffRow.querySelector(lineElSelector);
+    return undefined;
   }
 
-  getTargetDiffElement(): GrDiff | null {
-    if (!this.diffRow) return null;
+  getTargetLineNumber(): LineNumber | undefined {
+    const diffRow = this.getTargetDiffRow();
+    return diffRow?.lineNumber(this.side);
+  }
 
-    const hostOwner = this.diffRow.getRootNode() as ShadowRoot;
+  getTargetDiffElement(): GrDiff | undefined {
+    if (!this.diffRowTR) return undefined;
+
+    const hostOwner = this.diffRowTR.getRootNode() as ShadowRoot;
     if (hostOwner?.host?.tagName === 'GR-DIFF') {
       return hostOwner.host as GrDiff;
     }
-    return null;
+    return undefined;
   }
 
   moveToFirstChunk() {
     this.cursorManager.moveToStart();
-    if (this.diffRow && !this._isFirstRowOfChunk(this.diffRow)) {
+    if (this.diffRowTR && !this.isFirstRowOfChunk(this.diffRowTR)) {
       this.moveToNextChunk(true);
     } else {
-      this._fixSide();
+      this.fixSide();
     }
   }
 
   moveToLastChunk() {
     this.cursorManager.moveToEnd();
-    if (this.diffRow && !this._isFirstRowOfChunk(this.diffRow)) {
+    if (this.diffRowTR && !this.isFirstRowOfChunk(this.diffRowTR)) {
       this.moveToPreviousChunk();
     } else {
-      this._fixSide();
+      this.fixSide();
     }
   }
 
@@ -306,8 +299,8 @@
    * reset the scroll behavior, use reInitAndUpdateStops() instead.
    */
   reInitCursor() {
-    this._updateStops();
-    if (!this.diffRow) {
+    this.updateStops();
+    if (!this.diffRowTR) {
       // does not scroll during init unless requested
       this.cursorManager.scrollMode = this.initialLineNumber
         ? ScrollMode.KEEP_VISIBLE
@@ -326,7 +319,7 @@
     this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE;
   }
 
-  private _boundHandleWindowScroll = () => {
+  private boundHandleWindowScroll = () => {
     if (this.preventAutoScrollOnManualScroll) {
       this.cursorManager.scrollMode = ScrollMode.NEVER;
       this.cursorManager.focusOnMove = false;
@@ -336,25 +329,25 @@
 
   reInitAndUpdateStops() {
     this.resetScrollMode();
-    this._updateStops();
+    this.updateStops();
   }
 
   private boundHandleDiffLoadingChanged = () => {
-    this._updateStops();
+    this.updateStops();
   };
 
-  private _boundHandleDiffRenderStart = () => {
+  private boundHandleDiffRenderStart = () => {
     this.preventAutoScrollOnManualScroll = true;
   };
 
-  private _boundHandleDiffRenderContent = () => {
-    this._updateStops();
+  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 = (
+  private boundHandleDiffLineSelected = (
     e: CustomEvent<LineSelectedEventDetail>
   ) => {
     this.moveToLineNumber(e.detail.number, e.detail.side, e.detail.path);
@@ -367,73 +360,34 @@
     if (diffWithRangeSelected) {
       diffWithRangeSelected.createRangeComment();
     } else {
-      const line = this.getTargetLineElement();
+      const diffRow = this.getTargetDiffRow();
+      const lineNumber = diffRow?.lineNumber(this.side);
       const diff = this.getTargetDiffElement();
-      if (diff && line) {
-        diff.addDraftAtLine(line);
+      if (diff && lineNumber) {
+        diff.addDraftAtLine(lineNumber, this.side);
       }
     }
   }
 
-  /**
-   * 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) {
+  private getViewMode() {
+    if (!this.diffRowTR) {
       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')) {
+    if (this.diffRowTR.classList.contains('side-by-side')) {
       return DiffViewMode.SIDE_BY_SIDE;
     } else {
       return DiffViewMode.UNIFIED;
     }
   }
 
-  _rowHasSide(row: Element) {
+  private rowHasSide(row: Element) {
     const selector =
       (this.side === Side.LEFT ? '.left' : '.right') + ' + .content';
     return !!row.querySelector(selector);
   }
 
-  _isFirstRowOfChunk(row: HTMLElement) {
+  private isFirstRowOfChunk(row: HTMLElement) {
     const chunk = fromRowToChunk(row);
     if (!chunk) return false;
 
@@ -444,7 +398,7 @@
     return firstRow === row;
   }
 
-  _rowHasThread(row: HTMLElement): boolean {
+  private rowHasThread(row: HTMLElement): boolean {
     const slots = [
       ...row.querySelectorAll<HTMLSlotElement>('.thread-group > slot'),
     ];
@@ -455,70 +409,38 @@
    * If we jumped to a row where there is no content on the current side then
    * switch to the alternate side.
    */
-  _fixSide() {
+  private fixSide() {
     if (
-      this._getViewMode() === DiffViewMode.SIDE_BY_SIDE &&
-      this._isTargetBlank()
+      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 isTargetBlank() {
+    const line = this.getTargetDiffRow()?.line(this.side);
+    return line?.type === GrDiffLineType.BLANK;
   }
 
   private fireCursorMoved(
-    event: 'line-cursor-moved-out' | 'line-cursor-moved-in',
-    row: HTMLElement,
-    side: Side
+    event: 'line-cursor-moved-out' | 'line-cursor-moved-in'
   ) {
-    const address = this.getAddressFor(row, side);
-    if (address) {
-      const {leftSide, number} = address;
-      fire(row, event, {
-        lineNum: number,
-        side: leftSide ? Side.LEFT : Side.RIGHT,
-      });
-    }
+    const lineNum = this.getTargetLineNumber();
+    if (!lineNum) return;
+    fire(this.diffRowTR, event, {lineNum, side: this.side});
   }
 
   private updateSideClass() {
-    if (!this.diffRow) {
+    if (!this.diffRowTR) {
       return;
     }
-    toggleClass(this.diffRow, LEFT_SIDE_CLASS, this.side === Side.LEFT);
-    toggleClass(this.diffRow, RIGHT_SIDE_CLASS, this.side === Side.RIGHT);
+    this.diffRowTR.classList.toggle(LEFT_SIDE_CLASS, this.side === Side.LEFT);
+    this.diffRowTR.classList.toggle(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() {
+  // visible for testing
+  updateStops() {
     this.cursorManager.stops = this.diffs.reduce(
       (stops: Stop[], diff) => stops.concat(diff.getCursorStops()),
       []
@@ -534,7 +456,7 @@
       this.addEventListeners(diff);
     }
     this.diffs.push(...diffs);
-    this._updateStops();
+    this.updateStops();
   }
 
   unregisterDiff(diff: GrDiffCursorable) {
@@ -551,15 +473,12 @@
       'loading-changed',
       this.boundHandleDiffLoadingChanged
     );
-    diff.removeEventListener('render-start', this._boundHandleDiffRenderStart);
+    diff.removeEventListener('render-start', this.boundHandleDiffRenderStart);
     diff.removeEventListener(
       'render-content',
-      this._boundHandleDiffRenderContent
+      this.boundHandleDiffRenderContent
     );
-    diff.removeEventListener(
-      'line-selected',
-      this._boundHandleDiffLineSelected
-    );
+    diff.removeEventListener('line-selected', this.boundHandleDiffLineSelected);
   }
 
   private addEventListeners(diff: GrDiffCursorable) {
@@ -567,12 +486,13 @@
       'loading-changed',
       this.boundHandleDiffLoadingChanged
     );
-    diff.addEventListener('render-start', this._boundHandleDiffRenderStart);
-    diff.addEventListener('render-content', this._boundHandleDiffRenderContent);
-    diff.addEventListener('line-selected', this._boundHandleDiffLineSelected);
+    diff.addEventListener('render-start', this.boundHandleDiffRenderStart);
+    diff.addEventListener('render-content', this.boundHandleDiffRenderContent);
+    diff.addEventListener('line-selected', this.boundHandleDiffLineSelected);
   }
 
-  _findRowByNumberAndFile(
+  // visible for testing
+  findRowByNumberAndFile(
     targetNumber: LineNumber,
     side: Side,
     path?: string
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
index 61f8551..70fece3 100644
--- 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
@@ -17,57 +17,55 @@
 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 {DiffInfo, DiffViewMode, FILE, 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>`);
+    diffElement.path = 'some/path.ts';
     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.updateStops();
       cursor.moveToFirstChunk();
       diffElement.removeEventListener('render', setupDone);
       promise.resolve();
     };
     diffElement.addEventListener('render', setupDone);
 
-    diff = createDiff();
-    diffElement.prefs = createDefaultDiffPrefs();
-    diffElement.diff = diff;
+    diffElement.diffModel.updateState({
+      diff: createDiff(),
+      path: 'some/path.ts',
+      diffPrefs: createDefaultDiffPrefs(),
+    });
     await promise;
   });
 
   test('diff cursor functionality (side-by-side)', () => {
-    assert.isOk(cursor.diffRow);
+    assert.isOk(cursor.diffRowTR);
 
     const deltaRows = queryAll<HTMLTableRowElement>(
       diffElement,
       '.section.delta tr.diff-row'
     );
-    assert.equal(cursor.diffRow, deltaRows[0]);
+    assert.equal(cursor.diffRowTR, deltaRows[0]);
 
     cursor.moveDown();
 
-    assert.notEqual(cursor.diffRow, deltaRows[0]);
-    assert.equal(cursor.diffRow, deltaRows[1]);
+    assert.notEqual(cursor.diffRowTR, deltaRows[0]);
+    assert.equal(cursor.diffRowTR, deltaRows[1]);
 
     cursor.moveUp();
 
-    assert.notEqual(cursor.diffRow, deltaRows[1]);
-    assert.equal(cursor.diffRow, deltaRows[0]);
+    assert.notEqual(cursor.diffRowTR, deltaRows[1]);
+    assert.equal(cursor.diffRowTR, deltaRows[0]);
   });
 
   test('moveToFirstChunk', async () => {
@@ -98,13 +96,16 @@
       ],
     };
 
-    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;
+    diffElement.diffModel.updateState({
+      diff,
+      // The file comment button, if present, is a cursor stop. Ensure
+      // moveToFirstChunk() works correctly even if the button is not shown.
+      diffPrefs: {...createDefaultDiffPrefs(), show_file_comment_button: false},
+    });
+    await waitForEventOnce(diffElement, 'render');
     await waitForEventOnce(diffElement, 'render');
 
-    cursor._updateStops();
+    cursor.updateStops();
 
     const chunks = [
       ...queryAll(diffElement, '.section.delta'),
@@ -118,19 +119,19 @@
 
     // Verify it works on fresh diff.
     cursor.moveToFirstChunk();
-    assert.ok(cursor.diffRow);
-    assert.equal(cursor.diffRow, rows[0]);
+    assert.ok(cursor.diffRowTR);
+    assert.equal(cursor.diffRowTR, 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.ok(cursor.diffRowTR);
+    assert.equal(cursor.diffRowTR, rows[1]);
     assert.equal(cursor.side, Side.LEFT);
 
     cursor.moveToFirstChunk();
-    assert.ok(cursor.diffRow);
-    assert.equal(cursor.diffRow, rows[0]);
+    assert.ok(cursor.diffRowTR);
+    assert.equal(cursor.diffRowTR, rows[0]);
     assert.equal(cursor.side, Side.RIGHT);
   });
 
@@ -161,10 +162,11 @@
         {b: ['new line 3']},
       ],
     };
+    diffElement.diffModel.updateState({diff});
 
-    diffElement.diff = diff;
     await waitForEventOnce(diffElement, 'render');
-    cursor._updateStops();
+    await waitForEventOnce(diffElement, 'render');
+    cursor.updateStops();
 
     const chunks = [
       ...queryAll(diffElement, '.section.delta'),
@@ -178,19 +180,19 @@
 
     // Verify it works on fresh diff.
     cursor.moveToLastChunk();
-    assert.ok(cursor.diffRow);
-    assert.equal(cursor.diffRow, rows[1]);
+    assert.ok(cursor.diffRowTR);
+    assert.equal(cursor.diffRowTR, 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.ok(cursor.diffRowTR);
+    assert.equal(cursor.diffRowTR, rows[0]);
     assert.equal(cursor.side, Side.LEFT);
 
     cursor.moveToLastChunk();
-    assert.ok(cursor.diffRow);
-    assert.equal(cursor.diffRow, rows[1]);
+    assert.ok(cursor.diffRowTR);
+    assert.equal(cursor.diffRowTR, rows[1]);
     assert.equal(cursor.side, Side.RIGHT);
   });
 
@@ -228,35 +230,37 @@
 
   suite('unified diff', () => {
     setup(async () => {
-      diffElement.viewMode = DiffViewMode.UNIFIED;
-      await waitForEventOnce(diffElement, 'render');
+      diffElement.diffModel.updateState({
+        renderPrefs: {view_mode: DiffViewMode.UNIFIED},
+      });
+      await diffElement.updateComplete;
       cursor.reInitCursor();
     });
 
     test('diff cursor functionality (unified)', () => {
-      assert.isOk(cursor.diffRow);
+      assert.isOk(cursor.diffRowTR);
 
       const rows = [
         ...queryAll(diffElement, '.section.delta tr.diff-row'),
       ] as HTMLTableRowElement[];
-      assert.equal(cursor.diffRow, rows[0]);
+      assert.equal(cursor.diffRowTR, rows[0]);
 
       cursor.moveDown();
 
-      assert.notEqual(cursor.diffRow, rows[0]);
-      assert.equal(cursor.diffRow, rows[1]);
+      assert.notEqual(cursor.diffRowTR, rows[0]);
+      assert.equal(cursor.diffRowTR, rows[1]);
 
       cursor.moveUp();
 
-      assert.notEqual(cursor.diffRow, rows[1]);
-      assert.equal(cursor.diffRow, rows[0]);
+      assert.notEqual(cursor.diffRowTR, rows[1]);
+      assert.equal(cursor.diffRowTR, 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');
+    diffElement.diffModel.updateState({
+      renderPrefs: {view_mode: DiffViewMode.SIDE_BY_SIDE},
+    });
 
     const rows = [
       ...queryAll(diffElement, '.section tr.diff-row'),
@@ -272,7 +276,7 @@
     // 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]);
+    assert.equal(cursor.diffRowTR, deltaRows[0]);
     const firstIndex = cursor.cursorManager.index;
 
     // Move the side to the left. Because this delta only has a right side, we
@@ -281,8 +285,8 @@
     cursor.moveLeft();
 
     assert.equal(cursor.side, Side.LEFT);
-    assert.notEqual(cursor.diffRow, rows[0]);
-    assert.equal(cursor.diffRow, rowBeforeFirstDelta);
+    assert.notEqual(cursor.diffRowTR, rows[0]);
+    assert.equal(cursor.diffRowTR, rowBeforeFirstDelta);
     assert.equal(cursor.cursorManager.index, firstIndex - 1);
 
     // If we move down, we should skip everything in the first delta because
@@ -290,8 +294,8 @@
     cursor.moveDown();
 
     assert.equal(cursor.side, Side.LEFT);
-    assert.notEqual(cursor.diffRow, rowBeforeFirstDelta);
-    assert.notEqual(cursor.diffRow, rows[0]);
+    assert.notEqual(cursor.diffRowTR, rowBeforeFirstDelta);
+    assert.notEqual(cursor.diffRowTR, rows[0]);
     assert.isTrue(cursor.cursorManager.index > firstIndex);
   });
 
@@ -300,7 +304,7 @@
 
     // 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.diffRowTR, deltaChunks[0].querySelector('tr'));
     assert.equal(cursor.side, Side.RIGHT);
 
     // Move to the next chunk.
@@ -308,147 +312,10 @@
 
     // 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.diffRowTR, 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');
@@ -457,9 +324,15 @@
       .callsFake(() => {
         scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
       });
-    diffElement.diff = createDiff();
-    await diffElement.updateComplete;
-    await waitForEventOnce(diffElement, 'render');
+    cursor.dispose();
+    const diff = createDiff();
+    diff.content.push({ab: ['one more line']});
+    diffElement.diffModel.updateState({diff});
+
+    await Promise.all([
+      diffElement.updateComplete,
+      waitForEventOnce(diffElement, 'render'),
+    ]);
     cursor.reInitCursor();
     assert.isFalse(moveToNumStub.called);
     assert.isTrue(moveToChunkStub.called);
@@ -478,9 +351,10 @@
     cursor.initialLineNumber = 10;
     cursor.side = Side.RIGHT;
 
-    diffElement.diff = createDiff();
-    await diffElement.updateComplete;
-    await waitForEventOnce(diffElement, 'render');
+    cursor.dispose();
+    const diff = createDiff();
+    diff.content.push({ab: ['one more line']});
+    diffElement.diff = diff;
     cursor.reInitCursor();
     assert.isFalse(moveToChunkStub.called);
     assert.isTrue(moveToNumStub.called);
@@ -492,7 +366,7 @@
 
   test('getTargetDiffElement', () => {
     cursor.initialLineNumber = 1;
-    assert.isTrue(!!cursor.diffRow);
+    assert.isTrue(!!cursor.diffRowTR);
     assert.equal(cursor.getTargetDiffElement(), diffElement);
   });
 
@@ -558,51 +432,56 @@
         'createRangeComment'
       );
       const addDraftAtLineStub = sinon.stub(diffElement, 'addDraftAtLine');
-      cursor.diffRow = undefined;
+      cursor.diffRowTR = undefined;
       cursor.createCommentInPlace();
       assert.isFalse(createRangeCommentStub.called);
       assert.isFalse(addDraftAtLineStub.called);
     });
   });
 
-  test('getAddress', () => {
+  test('getTargetLineNumber', () => {
     // It should initialize to the first chunk: line 5 of the revision.
-    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 5});
+    assert.deepEqual(cursor.getTargetLineNumber(), 5);
+    assert.deepEqual(cursor.side, Side.RIGHT);
 
     // Revision line 4 is up.
     cursor.moveUp();
-    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 4});
+    assert.deepEqual(cursor.getTargetLineNumber(), 4);
+    assert.deepEqual(cursor.side, Side.RIGHT);
 
     // Base line 4 is left.
     cursor.moveLeft();
-    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 4});
+    assert.deepEqual(cursor.getTargetLineNumber(), 4);
+    assert.deepEqual(cursor.side, Side.LEFT);
 
     // Moving to the next chunk takes it back to the start.
     cursor.moveToNextChunk();
-    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 5});
+    assert.deepEqual(cursor.getTargetLineNumber(), 5);
+    assert.deepEqual(cursor.side, Side.RIGHT);
 
     // The following chunk is a removal starting on line 10 of the base.
     cursor.moveToNextChunk();
-    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 10});
+    assert.deepEqual(cursor.getTargetLineNumber(), 10);
+    assert.deepEqual(cursor.side, Side.LEFT);
 
     // Should be null if there is no selection.
     cursor.cursorManager.unsetCursor();
-    assert.isNotOk(cursor.getAddress());
+    assert.isUndefined(cursor.getTargetLineNumber());
   });
 
-  test('_findRowByNumberAndFile', () => {
+  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);
+    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 spy = sinon.spy(cursor, 'updateStops');
     const controls = queryAndAssert(diffElement, 'gr-context-controls');
     const showContext = queryAndAssert<HTMLElement>(controls, '.showContext');
     showContext.click();
@@ -612,7 +491,7 @@
   });
 
   test('updates stops when loading changes', () => {
-    const spy = sinon.spy(cursor, '_updateStops');
+    const spy = sinon.spy(cursor, 'updateStops');
     diffElement.dispatchEvent(new Event('loading-changed'));
     assert.isTrue(spy.called);
   });
@@ -659,26 +538,17 @@
 
       // Goto second last line of the first diff
       cursor.moveToLineNumber(lastLine - 1, Side.RIGHT);
-      assert.equal(
-        cursor.getTargetLineElement()!.textContent?.trim(),
-        `${lastLine - 1}`
-      );
+      assert.equal(cursor.getTargetLineNumber(), lastLine - 1);
 
       // Can move down until we reach the loading file
       cursor.moveDown();
       assert.equal(getTargetDiffIndex(), 0);
-      assert.equal(
-        cursor.getTargetLineElement()!.textContent?.trim(),
-        lastLine.toString()
-      );
+      assert.equal(cursor.getTargetLineNumber(), lastLine);
 
       // Cannot move down while still loading the diff we would switch to
       cursor.moveDown();
       assert.equal(getTargetDiffIndex(), 0);
-      assert.equal(
-        cursor.getTargetLineElement()!.textContent?.trim(),
-        lastLine.toString()
-      );
+      assert.equal(cursor.getTargetLineNumber(), lastLine);
 
       // Diff 1 finishing to load
       diffElements[1].diff = createDiff();
@@ -688,7 +558,7 @@
       cursor.moveDown(); // LOST
       cursor.moveDown(); // FILE
       assert.equal(getTargetDiffIndex(), 1);
-      assert.equal(cursor.getTargetLineElement()!.textContent?.trim(), 'File');
+      assert.equal(cursor.getTargetLineNumber(), 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
index 38bd707..bddfcac 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {getSanitizeDOMValue} from '@polymer/polymer/lib/utils/settings';
+import {GrAnnotation} from '../../../api/diff';
 
 // TODO(wyatta): refactor this to be <MARK> rather than <HL>.
 const ANNOTATION_TAG = 'HL';
@@ -11,267 +12,271 @@
 // 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 || '');
-  },
+/**
+ * The DOM API textContent.length calculation is broken when the text
+ * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
+ */
+export function getLength(node: Node) {
+  if (node instanceof Comment) return 0;
+  return 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;
-  },
+/**
+ * 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.
+ */
+export function 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
+/**
+ * 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.
+ */
+export function 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 = getLength(node);
+    // If the current node is completely before the offset.
+    if (offset > 0 && initialNodeLength <= offset) {
+      offset -= initialNodeLength;
+      continue;
+    }
+
+    if (offset > 0) {
+      node = splitNode(node, offset);
+      offset = 0;
+    }
+    if (getLength(node) > length) {
+      splitNode(node, length);
+    }
+    nestedNodes.push(node);
+
+    length -= 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.
+ */
+export function 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 = 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) {
+      _annotateText(node, offset, subLength, cssClass);
+    } else if (node instanceof Element) {
+      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.
+ */
+function 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.
+ */
+function splitAndWrapInHighlight(
+  node: Text,
+  offset: number,
+  cssClass: string,
+  firstPart?: boolean
+) {
+  if (
+    (getLength(node) === offset && firstPart) ||
+    (offset === 0 && !firstPart)
   ) {
-    const tagName = elSpec.tagName;
-    const attributes = elSpec.attributes || {};
-    let childNodes: Node[];
+    return wrapInHighlight(node, cssClass);
+  }
+  if (firstPart) {
+    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 = splitNode(node, offset) as Text;
+  }
+  return wrapInHighlight(node, cssClass);
+}
 
-    if (parent instanceof Element) {
-      childNodes = Array.from(parent.childNodes);
-    } else if (parent instanceof Text) {
-      childNodes = [parent];
-      parent = parent.parentNode!;
-    } else {
-      return;
+/**
+ * Splits Node at offset.
+ * If Node is Element, it's cloned and the node at offset is split too.
+ */
+function splitNode(element: Node, offset: number) {
+  if (element instanceof Text) {
+    return 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 && (getLength(node) <= offset || getLength(node) === 0)) {
+    offset -= getLength(node);
+    node = node.nextSibling;
+  }
+  if (node && getLength(node) > offset) {
+    tail.appendChild(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.
+ */
+function 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);
+  }
+}
 
-    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;
-      }
+function _annotateText(
+  node: Text,
+  offset: number,
+  length: number,
+  cssClass: string
+) {
+  const nodeLength = getLength(node);
 
-      if (offset > 0) {
-        node = this.splitNode(node, offset);
-        offset = 0;
-      }
-      if (this.getLength(node) > length) {
-        this.splitNode(node, length);
-      }
-      nestedNodes.push(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.
 
-      length -= this.getLength(node);
-      if (!length) break;
-    }
+  if (offset === 0 && nodeLength === length) {
+    // Case 1.
+    wrapInHighlight(node, cssClass);
+  } else if (offset === 0) {
+    // Case 2.
+    splitAndWrapInHighlight(node, length, cssClass, true);
+  } else if (offset + length === nodeLength) {
+    // Case 3
+    splitAndWrapInHighlight(node, offset, cssClass, false);
+  } else {
+    // Case 4
+    splitAndWrapInHighlight(
+      splitTextNode(node, offset),
+      length,
+      cssClass,
+      true
+    );
+  }
+}
 
-    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
-      );
-    }
-  },
+export const GrAnnotationImpl: GrAnnotation = {
+  annotateElement,
+  annotateWithElement,
 };
 
 /**
@@ -282,3 +287,8 @@
   tagName: string;
   attributes?: {[attributeName: string]: string | undefined};
 }
+
+export const TEST_ONLY = {
+  _annotateText,
+  splitTextNode,
+};
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
index f319a3c..378c255 100644
--- 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
@@ -3,9 +3,13 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../../test/common-test-setup';
-import {GrAnnotation} from './gr-annotation';
+import {
+  TEST_ONLY,
+  annotateElement,
+  annotateWithElement,
+  getStringLength,
+} from './gr-annotation';
 import {
   getSanitizeDOMValue,
   setSanitizeDOMValue,
@@ -28,7 +32,7 @@
   });
 
   test('_annotateText length:0 offset:0', () => {
-    GrAnnotation._annotateText(textNode, 0, 0, 'foobar');
+    TEST_ONLY._annotateText(textNode, 0, 0, 'foobar');
 
     assert.equal(parent.textContent, str);
     assert.equal(
@@ -38,7 +42,7 @@
   });
 
   test('_annotateText length:0 offset:1', () => {
-    GrAnnotation._annotateText(textNode, 1, 0, 'foobar');
+    TEST_ONLY._annotateText(textNode, 1, 0, 'foobar');
 
     assert.equal(parent.textContent, str);
     assert.equal(
@@ -48,7 +52,7 @@
   });
 
   test('_annotateText length:0 offset:str.length', () => {
-    GrAnnotation._annotateText(textNode, str.length, 0, 'foobar');
+    TEST_ONLY._annotateText(textNode, str.length, 0, 'foobar');
 
     assert.equal(parent.textContent, str);
     assert.equal(
@@ -58,7 +62,7 @@
   });
 
   test('_annotateText Case 1', () => {
-    GrAnnotation._annotateText(textNode, 0, str.length, 'foobar');
+    TEST_ONLY._annotateText(textNode, 0, str.length, 'foobar');
 
     assert.equal(parent.textContent, str);
     assert.equal(
@@ -68,7 +72,7 @@
   });
 
   test('_annotateText Case 2', () => {
-    GrAnnotation._annotateText(textNode, 0, 12, 'foobar');
+    TEST_ONLY._annotateText(textNode, 0, 12, 'foobar');
 
     assert.equal(parent.textContent, str);
     assert.equal(
@@ -78,7 +82,7 @@
   });
 
   test('_annotateText Case 3', () => {
-    GrAnnotation._annotateText(textNode, 12, str.length - 12, 'foobar');
+    TEST_ONLY._annotateText(textNode, 12, str.length - 12, 'foobar');
 
     assert.equal(parent.textContent, str);
     assert.equal(
@@ -91,7 +95,7 @@
     const index = str.indexOf('dolor');
     const length = 'dolor '.length;
 
-    GrAnnotation._annotateText(textNode, index, length, 'foobar');
+    TEST_ONLY._annotateText(textNode, index, length, 'foobar');
 
     assert.equal(parent.textContent, str);
     assert.equal(
@@ -105,7 +109,7 @@
 
     // Apply the layers successively.
     layers.forEach((layer, i) => {
-      GrAnnotation.annotateElement(
+      annotateElement(
         parent,
         str.indexOf(layer),
         layer.length,
@@ -130,13 +134,13 @@
 
     // Non-unicode path:
     node = document.createTextNode(helloString + asciiString);
-    tail = GrAnnotation.splitTextNode(node, helloString.length);
+    tail = TEST_ONLY.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);
+    tail = TEST_ONLY.splitTextNode(node, helloString.length);
     assert(node.textContent, helloString);
     assert(tail.textContent, unicodeString);
   });
@@ -145,11 +149,11 @@
     const fullText = '01234567890123456789';
     let mockSanitize: sinon.SinonSpy;
     let originalSanitizeDOMValue: (
-      p0: any,
-      p1: string,
-      p2: string,
-      p3: Node | null
-    ) => any;
+      value: unknown,
+      name: string,
+      type: 'property' | 'attribute',
+      node: Node | null | undefined
+    ) => unknown;
 
     setup(() => {
       setSanitizeDOMValue(p0 => p0);
@@ -167,7 +171,7 @@
       const length = 10;
       const container = document.createElement('div');
       container.textContent = fullText;
-      GrAnnotation.annotateWithElement(container, 1, length, {
+      annotateWithElement(container, 1, length, {
         tagName: 'test-wrapper',
       });
 
@@ -181,8 +185,8 @@
       const length = 10;
       const container = document.createElement('div');
       container.textContent = fullText;
-      GrAnnotation.annotateElement(container, 5, length, 'testclass');
-      GrAnnotation.annotateWithElement(container, 1, length, {
+      annotateElement(container, 5, length, 'testclass');
+      annotateWithElement(container, 1, length, {
         tagName: 'test-wrapper',
       });
 
@@ -202,7 +206,7 @@
       const length = 10;
       const container = document.createElement('div');
       container.textContent = fullText;
-      GrAnnotation.annotateWithElement(container.childNodes[0], 1, length, {
+      annotateWithElement(container.childNodes[0], 1, length, {
         tagName: 'test-wrapper',
       });
 
@@ -217,7 +221,7 @@
       container.appendChild(document.createTextNode('0123456789'));
       container.appendChild(document.createElement('span'));
       container.appendChild(document.createTextNode('0123456789'));
-      GrAnnotation.annotateWithElement(container, 1, 10, {
+      annotateWithElement(container, 1, 10, {
         tagName: 'test-wrapper',
       });
 
@@ -234,7 +238,7 @@
       container.appendChild(document.createComment('comment2'));
       container.appendChild(document.createElement('span'));
       container.appendChild(document.createTextNode('0123456789'));
-      GrAnnotation.annotateWithElement(container, 1, 10, {
+      annotateWithElement(container, 1, 10, {
         tagName: 'test-wrapper',
       });
 
@@ -255,7 +259,7 @@
         'data-foo': 'bar',
         class: 'hello world',
       };
-      GrAnnotation.annotateWithElement(container, 1, length, {
+      annotateWithElement(container, 1, length, {
         tagName: 'test-wrapper',
         attributes,
       });
@@ -292,17 +296,17 @@
 
   suite('getStringLength', () => {
     test('ASCII characters are counted correctly', () => {
-      assert.equal(GrAnnotation.getStringLength('ASCII'), 5);
+      assert.equal(getStringLength('ASCII'), 5);
     });
 
     test('Unicode surrogate pairs count as one symbol', () => {
-      assert.equal(GrAnnotation.getStringLength('Unic💢de'), 7);
-      assert.equal(GrAnnotation.getStringLength('💢💢'), 2);
+      assert.equal(getStringLength('Unic💢de'), 7);
+      assert.equal(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̣̇
+      assert.equal(getStringLength('man\u0303ana'), 7); // mañana
+      assert.equal(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
index 69c0f5c..5f890ce 100644
--- 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
@@ -5,22 +5,21 @@
  */
 import '../../../styles/shared-styles';
 import '../gr-selection-action-box/gr-selection-action-box';
-import {GrAnnotation} from './gr-annotation';
+import {getLength} 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,
+  GrDiffCommentThread,
 } from '../gr-diff/gr-diff-utils';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
-import {fire} from '../../../utils/event-util';
+import {DiffModel} from '../gr-diff-model/gr-diff-model';
 
 interface SidedRange {
   side: Side;
@@ -45,6 +44,7 @@
  */
 export interface DiffBuilderInterface {
   getContentTdByLineEl(lineEl?: Element): Element | undefined;
+  diffModel: DiffModel;
 }
 
 /**
@@ -135,64 +135,31 @@
     );
   }
 
-  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,
+    thread: GrDiffCommentThread,
     highlightRange = false
   ) {
-    const rootId = threadEl?.rootId;
+    const rootId = thread?.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')
-        );
+    const highlightClass = highlightRange ? 'range' : 'rangeHoverHighlight';
+    const selector = `.${highlightClass}.${strToClassName(rootId)}`;
+    const rangeNodes = this.diffTable.querySelectorAll(selector);
+    for (const rangeNode of rangeNodes) {
+      rangeNode.classList.toggle('rangeHoverHighlight', highlightRange);
     }
   }
 
-  private handleCommentThreadMouseenter = (e: Event) => {
-    const threadEl = this.getThreadEl(e);
-    this.toggleRangeElHighlight(threadEl, /* highlightRange= */ true);
+  private handleCommentThreadMouseenter = (
+    e: CustomEvent<GrDiffCommentThread>
+  ) => {
+    this.toggleRangeElHighlight(e.detail, /* highlightRange= */ true);
   };
 
-  private handleCommentThreadMouseleave = (e: Event) => {
-    const threadEl = this.getThreadEl(e);
-    this.toggleRangeElHighlight(threadEl, /* highlightRange= */ false);
+  private handleCommentThreadMouseleave = (
+    e: CustomEvent<GrDiffCommentThread>
+  ) => {
+    this.toggleRangeElHighlight(e.detail, /* highlightRange= */ false);
   };
 
   /**
@@ -308,7 +275,7 @@
     const side = getSideByLineEl(lineEl);
     if (!side) return null;
     const line = getLineNumberByChild(lineEl);
-    if (!line || line === FILE || line === 'LOST') return null;
+    if (typeof line !== 'number') return null;
     const contentTd = this.diffBuilder.getContentTdByLineEl(lineEl);
     if (!contentTd) return null;
     const contentText = contentTd.querySelector('.contentText');
@@ -408,7 +375,7 @@
       // 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, {
+        this.createRangeComment(start.side, {
           start_line: start.line,
           start_character: 0,
           end_line: start.line,
@@ -458,10 +425,9 @@
     }
   }
 
-  private fireCreateRangeComment(side: Side, range: CommentRange) {
-    if (this.diffTable) {
-      fire(this.diffTable, 'create-range-comment', {side, range});
-    }
+  private createRangeComment(side: Side, range: CommentRange) {
+    assertIsDefined(this.diffBuilder, 'diffBuilder');
+    this.diffBuilder?.diffModel.createCommentOnRange(range, side);
     this.removeActionBox();
   }
 
@@ -469,7 +435,7 @@
     e.stopPropagation();
     assertIsDefined(this.selectedRange, 'selectedRange');
     const {side, range} = this.selectedRange;
-    this.fireCreateRangeComment(side, range);
+    this.createRangeComment(side, range);
   };
 
   // visible for testing
@@ -509,18 +475,7 @@
     if (node instanceof Element && node.classList.contains('content')) {
       return this.getLength(queryAndAssert(node, '.contentText'));
     } else {
-      return GrAnnotation.getLength(node);
+      return 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
index f04e6a2..32decb1 100644
--- 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
@@ -7,21 +7,21 @@
 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 {GrDiffHighlight, DiffBuilderInterface} 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 {
+  GrDiffThreadElement,
+  getDataFromCommentThreadEl,
+} 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';
+import {DiffModel} from '../gr-diff-model/gr-diff-model';
 
 // Splitting long lines in html into shorter rows breaks tests:
 // zero-length text nodes and new lines are not expected in some places
@@ -132,16 +132,13 @@
     let hlRange: HTMLElement;
     let element: GrDiffHighlight;
     let diff: HTMLElement;
-    let builder: {
-      getContentTdByLineEl: SinonStubbedMember<
-        DiffBuilderInterface['getContentTdByLineEl']
-      >;
-    };
+    let builder: DiffBuilderInterface;
 
     setup(async () => {
       diff = await fixture<HTMLTableElement>(diffTable);
       builder = {
         getContentTdByLineEl: sinon.stub(),
+        diffModel: new DiffModel(document),
       };
       element = new GrDiffHighlight();
       element.init(diff, builder);
@@ -152,6 +149,8 @@
       ) as unknown as GrDiffThreadElement;
       threadEl.className = 'comment-thread';
       threadEl.rootId = 'id314';
+      threadEl.setAttribute('line-num', '12');
+      threadEl.setAttribute('diff-side', 'right');
       diff.appendChild(threadEl);
     });
 
@@ -164,6 +163,7 @@
       assert.isFalse(hlRange.classList.contains('rangeHoverHighlight'));
       threadEl.dispatchEvent(
         new CustomEvent('comment-thread-mouseenter', {
+          detail: getDataFromCommentThreadEl(threadEl),
           bubbles: true,
           composed: true,
         })
@@ -176,6 +176,7 @@
       hlRange.classList.add('rangeHoverHighlight');
       threadEl.dispatchEvent(
         new CustomEvent('comment-thread-mouseleave', {
+          detail: getDataFromCommentThreadEl(threadEl),
           bubbles: true,
           composed: true,
         })
@@ -187,23 +188,23 @@
     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 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);
+      element.selectedRange = {side: Side.LEFT, range};
+      const createCommentStub = sinon.stub(
+        builder.diffModel,
+        'createCommentOnRange'
+      );
+
+      diff.dispatchEvent(new CustomEvent('create-comment-requested'));
+
+      assert.isTrue(createCommentStub.called);
+      assert.deepEqual(createCommentStub.lastCall.args[0], range);
+      assert.equal(createCommentStub.lastCall.args[1], Side.LEFT);
       assert.isTrue(removeActionBoxStub.called);
     });
   });
@@ -211,18 +212,18 @@
   suite('selection', () => {
     let element: GrDiffHighlight;
     let diff: HTMLElement;
-    let builder: {
-      getContentTdByLineEl: SinonStubbedMember<
-        DiffBuilderInterface['getContentTdByLineEl']
-      >;
-    };
+    let getContentTdByLineElStub: SinonStubbedMember<
+      DiffBuilderInterface['getContentTdByLineEl']
+    >;
     let contentStubs;
 
     setup(async () => {
       diff = await fixture<HTMLTableElement>(diffTable);
-      builder = {
-        getContentTdByLineEl: sinon.stub(),
+      const builder: DiffBuilderInterface = {
+        getContentTdByLineEl: () => undefined,
+        diffModel: new DiffModel(document),
       };
+      getContentTdByLineElStub = sinon.stub(builder, 'getContentTdByLineEl');
       element = new GrDiffHighlight();
       element.init(diff, builder);
       contentStubs = [];
@@ -251,7 +252,7 @@
         contentTd,
         contentText,
       });
-      builder.getContentTdByLineEl.withArgs(lineEl).returns(contentTd);
+      getContentTdByLineElStub.withArgs(lineEl).returns(contentTd);
       return contentText;
     };
 
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
index 645de1b..e0febdc 100644
--- 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
@@ -150,211 +150,218 @@
   // 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;
-      /*
+  static override get styles() {
+    return [
+      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;
-    }
-  `;
+          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 =
@@ -450,7 +457,14 @@
         <paper-button class=${classMap(leftClasses)} @click=${this.selectBase}>
           Base
         </paper-button>
-        <paper-fab mini icon="gr-icons:swapHoriz" @click=${this.manualBlink}>
+        <paper-fab
+          mini
+          icon="gr-icons:swapHoriz"
+          title=${this.baseSelected
+            ? 'switch to Revision version'
+            : 'switch to Base version'}
+          @click=${this.manualBlink}
+        >
         </paper-fab>
         <paper-button
           class=${classMap(rightClasses)}
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
index 21a7cf8..a05b5e2 100644
--- 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
@@ -75,39 +75,43 @@
     }
   );
 
-  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;
-    }
-  `;
+  static override get styles() {
+    return [
+      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`
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
index 7b46c51..3b778b1 100644
--- 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
@@ -25,25 +25,29 @@
 
   @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;
-    }
-  `;
+  static override get styles() {
+    return [
+      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`
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts b/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
index 8fbda14..4a0778b 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
@@ -3,45 +3,280 @@
  * Copyright 2023 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {Observable} from 'rxjs';
-import {filter} from 'rxjs/operators';
+import {Observable, combineLatest, from} from 'rxjs';
+import {debounceTime, filter, switchMap, withLatestFrom} from 'rxjs/operators';
 import {
+  CreateCommentEventDetail,
   DiffInfo,
+  DiffLayer,
   DiffPreferencesInfo,
+  DiffResponsiveMode,
+  DiffViewMode,
+  DisplayLine,
+  LineNumber,
+  LineSelectedEventDetail,
   RenderPreferences,
+  Side,
 } from '../../../api/diff';
 import {define} from '../../../models/dependency';
-import {Model} from '../../../models/model';
-import {isDefined} from '../../../types/types';
+import {Model} from '../../../models/base/model';
 import {select} from '../../../utils/observable-util';
+import {
+  FullContext,
+  GrDiffCommentThread,
+  KeyLocations,
+  computeContext,
+  computeKeyLocations,
+  computeLineLength,
+  getResponsiveMode,
+} from '../gr-diff/gr-diff-utils';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {
+  GrDiffProcessor,
+  ProcessingOptions,
+} from '../gr-diff-processor/gr-diff-processor';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {assert} from '../../../utils/common-util';
+import {isImageDiff} from '../../../utils/diff-util';
+import {ImageInfo} from '../../../types/common';
+import {fire} from '../../../utils/event-util';
+import {CommentRange} from '../../../api/rest-api';
 
 export interface DiffState {
-  diff: DiffInfo;
+  diff?: DiffInfo;
+  baseImage?: ImageInfo;
+  revisionImage?: ImageInfo;
   path?: string;
   renderPrefs: RenderPreferences;
   diffPrefs: DiffPreferencesInfo;
+  lineOfInterest?: DisplayLine;
+  comments: GrDiffCommentThread[];
+  groups: GrDiffGroup[];
+  /** how much context to show for large files */
+  showFullContext: FullContext;
+  errorMessage?: string;
+  layers: DiffLayer[];
 }
 
+export interface ColumnsToShow {
+  blame: boolean;
+  leftNumber: boolean;
+  leftSign: boolean;
+  leftContent: boolean;
+  rightNumber: boolean;
+  rightSign: boolean;
+  rightContent: boolean;
+}
+
+export const NO_COLUMNS: ColumnsToShow = {
+  blame: false,
+  leftNumber: false,
+  leftSign: false,
+  leftContent: false,
+  rightNumber: false,
+  rightSign: false,
+  rightContent: false,
+};
+
 export const diffModelToken = define<DiffModel>('diff-model');
 
-export class DiffModel extends Model<DiffState | undefined> {
+export class DiffModel extends Model<DiffState> {
   readonly diff$: Observable<DiffInfo> = select(
-    this.state$.pipe(filter(isDefined)),
-    diffState => diffState.diff
+    this.state$.pipe(filter(state => state.diff !== undefined)),
+    diffState => diffState.diff!
+  );
+
+  readonly baseImage$: Observable<ImageInfo | undefined> = select(
+    this.state$,
+    diffState => diffState.baseImage
+  );
+
+  readonly revisionImage$: Observable<ImageInfo | undefined> = select(
+    this.state$,
+    diffState => diffState.revisionImage
   );
 
   readonly path$: Observable<string | undefined> = select(
-    this.state$.pipe(filter(isDefined)),
+    this.state$,
     diffState => diffState.path
   );
 
   readonly renderPrefs$: Observable<RenderPreferences> = select(
-    this.state$.pipe(filter(isDefined)),
+    this.state$,
     diffState => diffState.renderPrefs
   );
 
+  readonly viewMode$: Observable<DiffViewMode> = select(
+    this.renderPrefs$,
+    renderPrefs => renderPrefs.view_mode ?? DiffViewMode.SIDE_BY_SIDE
+  );
+
+  readonly columnsToShow$: Observable<ColumnsToShow> = select(
+    this.renderPrefs$,
+    renderPrefs => {
+      const hideLeft = !!renderPrefs.hide_left_side;
+      const showSign = !!renderPrefs.show_sign_col;
+      const unified = renderPrefs.view_mode === DiffViewMode.UNIFIED;
+
+      return {
+        // TODO: Do not always render the blame column. Move this into renderPrefs.
+        blame: true,
+        // Hiding the left side in unified diff mode does not make a lot of sense and is not supported.
+        leftNumber: !hideLeft || unified,
+        leftSign: !hideLeft && showSign && !unified,
+        leftContent: !hideLeft && !unified,
+        rightNumber: true,
+        rightSign: showSign && !unified,
+        rightContent: true,
+      };
+    }
+  );
+
+  readonly columnCount$: Observable<number> = select(
+    this.columnsToShow$,
+    columnsToShow => Object.values(columnsToShow).filter(s => s).length
+  );
+
   readonly diffPrefs$: Observable<DiffPreferencesInfo> = select(
-    this.state$.pipe(filter(isDefined)),
+    this.state$,
     diffState => diffState.diffPrefs
   );
+
+  readonly layers$: Observable<DiffLayer[]> = select(
+    this.state$,
+    diffState => diffState.layers
+  );
+
+  readonly showFullContext$: Observable<FullContext> = select(
+    this.state$,
+    diffState => diffState.showFullContext
+  );
+
+  readonly context$: Observable<number> = select(this.state$, state =>
+    computeContext(
+      state.diffPrefs.context,
+      state.showFullContext,
+      createDefaultDiffPrefs().context
+    )
+  );
+
+  readonly responsiveMode$: Observable<DiffResponsiveMode> = select(
+    this.state$,
+    diffState => getResponsiveMode(diffState.diffPrefs, diffState.renderPrefs)
+  );
+
+  readonly errorMessage$: Observable<string | undefined> = select(
+    this.state$,
+    diffState => diffState.errorMessage
+  );
+
+  readonly comments$: Observable<GrDiffCommentThread[]> = select(
+    this.state$,
+    diffState => diffState.comments ?? []
+  );
+
+  readonly groups$: Observable<GrDiffGroup[]> = select(
+    this.state$,
+    diffState => diffState.groups ?? []
+  );
+
+  readonly loading$: Observable<boolean> = select(
+    this.state$,
+    diffState =>
+      (diffState.groups ?? []).length === 0 || diffState.diff === undefined
+  );
+
+  readonly lineLength$: Observable<number> = select(this.state$, state =>
+    computeLineLength(state.diffPrefs, state.path)
+  );
+
+  readonly keyLocations$: Observable<KeyLocations> = select(
+    this.state$,
+    diffState =>
+      computeKeyLocations(diffState.lineOfInterest, diffState.comments ?? [])
+  );
+
+  constructor(
+    /**
+     * Normally a reference to the <gr-diff> component. Used for firing events
+     * that are meant for <gr-diff> or the host of <gr-diff>. For tests this
+     * can also be just `document`.
+     */
+    private readonly eventTarget: EventTarget
+  ) {
+    super({
+      diffPrefs: createDefaultDiffPrefs(),
+      renderPrefs: {},
+      comments: [],
+      groups: [],
+      showFullContext: FullContext.UNDECIDED,
+      layers: [],
+    });
+    this.subscriptions = [this.processDiff()];
+  }
+
+  processDiff() {
+    return combineLatest([this.diff$, this.context$, this.renderPrefs$])
+      .pipe(
+        withLatestFrom(this.keyLocations$),
+        debounceTime(1),
+        switchMap(([[diff, context, renderPrefs], keyLocations]) => {
+          const options: ProcessingOptions = {
+            context,
+            keyLocations,
+            isBinary: !!(isImageDiff(diff) || diff.binary),
+          };
+          if (renderPrefs?.num_lines_rendered_at_once) {
+            options.asyncThreshold = renderPrefs.num_lines_rendered_at_once;
+          }
+          const processor = new GrDiffProcessor(options);
+          return from(processor.process(diff.content));
+        })
+      )
+      .subscribe(groups => {
+        this.updateState({groups});
+      });
+  }
+
+  /**
+   * Replace a context control group with some expanded groups. Happens when the
+   * user clicks "+10" or something similar.
+   */
+  replaceGroup(group: GrDiffGroup, newGroups: readonly GrDiffGroup[]) {
+    assert(
+      group.type === GrDiffGroupType.CONTEXT_CONTROL,
+      'gr-diff can only replace context control groups'
+    );
+    const groups = [...this.getState().groups];
+    const i = groups.indexOf(group);
+    if (i === -1) throw new Error('cannot find context control group');
+    groups.splice(i, 1, ...newGroups);
+    this.updateState({groups});
+  }
+
+  selectLine(number: LineNumber, side: Side) {
+    const path = this.getState().path;
+    if (!path) return;
+
+    const detail: LineSelectedEventDetail = {number, side, path};
+    fire(this.eventTarget, 'line-selected', detail);
+  }
+
+  createCommentOnLine(lineNum: LineNumber, side: Side) {
+    const detail: CreateCommentEventDetail = {
+      side,
+      lineNum,
+      range: undefined,
+    };
+    fire(this.eventTarget, 'create-comment', detail);
+  }
+
+  createCommentOnRange(range: CommentRange, side: Side) {
+    const detail: CreateCommentEventDetail = {
+      side,
+      lineNum: range.end_line,
+      range,
+    };
+    fire(this.eventTarget, 'create-comment', detail);
+  }
 }
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
index 05e5d3b..5db6db9 100644
--- 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
@@ -3,13 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {
-  GrDiffLine,
-  GrDiffLineType,
-  FILE,
-  Highlights,
-  LineNumber,
-} from '../gr-diff/gr-diff-line';
+import {GrDiffLine, Highlights} from '../gr-diff/gr-diff-line';
 import {
   GrDiffGroup,
   GrDiffGroupType,
@@ -18,10 +12,10 @@
 import {DiffContent} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
 import {debounce, DelayedTask} from '../../../utils/async-util';
-import {assert, assertIsDefined} from '../../../utils/common-util';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-
-const WHOLE_FILE = -1;
+import {assert} from '../../../utils/common-util';
+import {getStringLength} from '../gr-diff-highlight/gr-annotation';
+import {GrDiffLineType, LineNumber} from '../../../api/diff';
+import {FULL_CONTEXT, KeyLocations} from '../gr-diff/gr-diff-utils';
 
 // visible for testing
 export interface State {
@@ -37,11 +31,6 @@
   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.
@@ -61,6 +50,14 @@
   clearGroups(): void;
 }
 
+/** Interface for listening to the output of the processor. */
+export interface ProcessingOptions {
+  context: number;
+  keyLocations?: KeyLocations;
+  asyncThreshold?: number;
+  isBinary?: boolean;
+}
+
 /**
  * Converts the API's `DiffContent`s  to `GrDiffGroup`s for rendering.
  *
@@ -87,13 +84,15 @@
  *    the rest is not.
  */
 export class GrDiffProcessor {
-  context = 3;
+  // visible for testing
+  context: number;
 
-  consumer?: GroupConsumer;
+  // visible for testing
+  keyLocations: KeyLocations;
 
-  keyLocations: KeyLocations = {left: {}, right: {}};
+  private asyncThreshold: number;
 
-  asyncThreshold = 64;
+  private isBinary: boolean;
 
   // visible for testing
   isScrolling?: boolean;
@@ -106,6 +105,15 @@
 
   private resetIsScrollingTask?: DelayedTask;
 
+  private readonly groups: GrDiffGroup[] = [];
+
+  constructor(options: ProcessingOptions) {
+    this.context = options.context;
+    this.asyncThreshold = options.asyncThreshold ?? 64;
+    this.keyLocations = options.keyLocations ?? {left: {}, right: {}};
+    this.isBinary = options.isBinary ?? false;
+  }
+
   private readonly handleWindowScroll = () => {
     this.isScrolling = true;
     this.resetIsScrollingTask = debounce(
@@ -122,64 +130,24 @@
    * @return A promise that resolves with an
    * array of GrDiffGroups when the diff is completely processed.
    */
-  process(chunks: DiffContent[], isBinary: boolean) {
+  async process(chunks: DiffContent[]): Promise<GrDiffGroup[]> {
     assert(this.isStarted === false, 'diff processor cannot be started twice');
-    this.isStarted = true;
 
     window.addEventListener('scroll', this.handleWindowScroll);
 
-    assertIsDefined(this.consumer, 'consumer');
-    this.consumer.clearGroups();
-    this.consumer.addGroup(this.makeGroup('LOST'));
-    this.consumer.addGroup(this.makeGroup(FILE));
+    this.groups.push(this.makeGroup('LOST'));
+    this.groups.push(this.makeGroup('FILE'));
 
-    if (isBinary) return Promise.resolve();
-
-    return new Promise<void>(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.isCancelled || state.chunkIndex >= chunks.length) {
-          resolve();
-          return;
-        }
-        if (this.isScrolling) {
-          window.setTimeout(nextStep, 100);
-          return;
-        }
-
-        const stateUpdate = this.processNext(state, chunks);
-        for (const group of stateUpdate.groups) {
-          this.consumer?.addGroup(group);
-          currentBatch += group.lines.length;
-        }
-        state.lineNums.left += stateUpdate.lineDelta.left;
-        state.lineNums.right += stateUpdate.lineDelta.right;
-
-        state.chunkIndex = stateUpdate.newChunkIndex;
-        if (currentBatch >= this.asyncThreshold) {
-          currentBatch = 0;
-          window.setTimeout(nextStep, 1);
-        } else {
-          nextStep.call(this);
-        }
-      };
-
-      nextStep.call(this);
-    }).finally(() => {
+    if (this.isBinary) return this.groups;
+    try {
+      await this.processChunks(chunks);
+    } finally {
       this.finish();
-    });
+    }
+    return this.groups;
   }
 
   finish() {
-    this.consumer = undefined;
     window.removeEventListener('scroll', this.handleWindowScroll);
   }
 
@@ -188,6 +156,50 @@
     this.finish();
   }
 
+  async processChunks(chunks: DiffContent[]) {
+    let completed = () => {};
+    const promise = new Promise<void>(resolve => (completed = 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.isCancelled || state.chunkIndex >= chunks.length) {
+        completed();
+        return;
+      }
+      if (this.isScrolling) {
+        window.setTimeout(nextStep, 100);
+        return;
+      }
+
+      const stateUpdate = this.processNext(state, chunks);
+      for (const group of stateUpdate.groups) {
+        this.groups.push(group);
+        currentBatch += group.lines.length;
+      }
+      state.lineNums.left += stateUpdate.lineDelta.left;
+      state.lineNums.right += stateUpdate.lineDelta.right;
+
+      state.chunkIndex = stateUpdate.newChunkIndex;
+      if (currentBatch >= this.asyncThreshold) {
+        currentBatch = 0;
+        window.setTimeout(nextStep, 1);
+      } else {
+        nextStep.call(this);
+      }
+    };
+
+    nextStep.call(this);
+    await promise;
+  }
+
   /**
    * Process the next uncollapsible chunk, or the next collapsible chunks.
    */
@@ -276,7 +288,7 @@
     );
 
     const hasSkippedGroup = !!groups.find(g => g.skip);
-    if (this.context !== WHOLE_FILE || hasSkippedGroup) {
+    if (this.context !== FULL_CONTEXT || hasSkippedGroup) {
       const contextNumLines = this.context > 0 ? this.context : 0;
       const hiddenStart = state.chunkIndex === 0 ? 0 : contextNumLines;
       const hiddenEnd =
@@ -467,7 +479,10 @@
       // 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) {
+      if (
+        this.context === FULL_CONTEXT &&
+        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)});
@@ -618,7 +633,7 @@
     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);
+    const lineLengths = rows.map(r => getStringLength(r) + 1);
 
     let rowIndex = 0;
     let idx = 0;
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
index adcfff8..3485fe4 100644
--- 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
@@ -5,14 +5,15 @@
  */
 import '../../../test/common-test-setup';
 import './gr-diff-processor';
-import {GrDiffLineType, FILE, GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {GrDiffProcessor, State} from './gr-diff-processor';
+import {GrDiffProcessor, ProcessingOptions, State} from './gr-diff-processor';
 import {DiffContent} from '../../../types/diff';
 import {assert} from '@open-wc/testing';
+import {FILE, GrDiffLineType} from '../../../api/diff';
+import {FULL_CONTEXT} from '../gr-diff/gr-diff-utils';
 
 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 ' +
@@ -20,27 +21,20 @@
     'Eos cu aliquam labores qualisque, usu postea inermis te, et solum ' +
     'fugit assum per.';
 
-  let element: GrDiffProcessor;
-  let groups: GrDiffGroup[];
+  let processor: GrDiffProcessor;
+  let options: ProcessingOptions = {
+    context: 4,
+  };
 
   setup(() => {});
 
   suite('not logged in', () => {
     setup(() => {
-      groups = [];
-      element = new GrDiffProcessor();
-      element.consumer = {
-        addGroup(group: GrDiffGroup) {
-          groups.push(group);
-        },
-        clearGroups() {
-          groups = [];
-        },
-      };
-      element.context = 4;
+      options = {context: 4};
+      processor = new GrDiffProcessor(options);
     });
 
-    test('process loaded content', () => {
+    test('process loaded content', async () => {
       const content: DiffContent[] = [
         {
           ab: ['<!DOCTYPE html>', '<meta charset="utf-8">'],
@@ -58,81 +52,80 @@
         },
       ];
 
-      return element.process(content, false).then(() => {
-        groups.shift(); // remove portedThreadsWithoutRangeGroup
-        assert.equal(groups.length, 4);
+      const groups = await processor.process(content);
+      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);
+      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);
+      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;
-        }
+      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">',
-        ]);
+      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[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.',
-        ]);
-      });
+      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', () => {
+    test('first group is for file', async () => {
       const content = [{b: ['foo']}];
 
-      return element.process(content, false).then(() => {
-        groups.shift(); // remove portedThreadsWithoutRangeGroup
+      const groups = await processor.process(content);
+      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);
-      });
+      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;
+    suite('context groups', async () => {
+      test('at the beginning, larger than context', async () => {
+        options.context = 10;
+        processor = new GrDiffProcessor(options);
         const content = [
           {
             ab: Array.from<string>({length: 100}).fill(
@@ -142,28 +135,27 @@
           {a: ['all work and no play make andybons a dull boy']},
         ];
 
-        return element.process(content, false).then(() => {
-          groups.shift(); // remove portedThreadsWithoutRangeGroup
+        const groups = await processor.process(content);
+        // group[0] is the LOST group
+        // group[1] is the FILE group
 
-          // group[0] is the file group
+        assert.equal(groups[2].type, GrDiffGroupType.CONTEXT_CONTROL);
+        assert.instanceOf(groups[2].contextGroups[0], GrDiffGroup);
+        assert.equal(groups[2].contextGroups[0].lines.length, 90);
+        for (const l of groups[2].contextGroups[0].lines) {
+          assert.equal(l.text, 'all work and no play make jack a dull boy');
+        }
 
-          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');
-          }
-        });
+        assert.equal(groups[3].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[3].lines.length, 10);
+        for (const l of groups[3].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;
+        options.context = 10;
+        processor = new GrDiffProcessor(options);
         const content = [
           {
             ab: Array.from<string>({length: 20}).fill(
@@ -175,8 +167,7 @@
           {a: ['some other content']},
         ];
 
-        await element.process(content, false);
-
+        const groups = await processor.process(content);
         groups.shift(); // remove portedThreadsWithoutRangeGroup
 
         // group[0] is the file group
@@ -214,8 +205,9 @@
         }
       });
 
-      test('at the beginning, smaller than context', () => {
-        element.context = 10;
+      test('at the beginning, smaller than context', async () => {
+        options.context = 10;
+        processor = new GrDiffProcessor(options);
         const content = [
           {
             ab: Array.from<string>({length: 5}).fill(
@@ -225,21 +217,21 @@
           {a: ['all work and no play make andybons a dull boy']},
         ];
 
-        return element.process(content, false).then(() => {
-          groups.shift(); // remove portedThreadsWithoutRangeGroup
+        const groups = await processor.process(content);
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
 
-          // group[0] is the file group
+        // 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');
-          }
-        });
+        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;
+      test('at the end, larger than context', async () => {
+        options.context = 10;
+        processor = new GrDiffProcessor(options);
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
           {
@@ -249,29 +241,28 @@
           },
         ];
 
-        return element.process(content, false).then(() => {
-          groups.shift(); // remove portedThreadsWithoutRangeGroup
+        const groups = await processor.process(content);
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
 
-          // group[0] is the file group
-          // group[1] is the "a" group
+        // 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[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');
-          }
-        });
+        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;
+      test('at the end, smaller than context', async () => {
+        options.context = 10;
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
           {
@@ -281,22 +272,22 @@
           },
         ];
 
-        return element.process(content, false).then(() => {
-          groups.shift(); // remove portedThreadsWithoutRangeGroup
+        const groups = await processor.process(content);
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
 
-          // group[0] is the file group
-          // group[1] is the "a" group
+        // 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');
-          }
-        });
+        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;
+      test('for interleaved ab and common: true chunks', async () => {
+        options.context = 10;
+        processor = new GrDiffProcessor(options);
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
           {
@@ -334,84 +325,75 @@
           },
         ];
 
-        return element.process(content, false).then(() => {
-          groups.shift(); // remove portedThreadsWithoutRangeGroup
+        const groups = await processor.process(content);
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
 
-          // group[0] is the file group
-          // group[1] is the "a" group
+        // 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)
+        // 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[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[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');
-          }
+        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
+        // 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[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].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'
-            );
-          }
+        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');
-          }
-        });
+        // 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;
+      test('in the middle, larger than context', async () => {
+        options.context = 10;
+        processor = new GrDiffProcessor(options);
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
           {
@@ -422,35 +404,35 @@
           {a: ['all work and no play make andybons a dull boy']},
         ];
 
-        return element.process(content, false).then(() => {
-          groups.shift(); // remove portedThreadsWithoutRangeGroup
+        const groups = await processor.process(content);
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
 
-          // group[0] is the file group
-          // group[1] is the "a" group
+        // 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[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[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');
-          }
-        });
+        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;
+      test('in the middle, smaller than context', async () => {
+        options.context = 10;
+        processor = new GrDiffProcessor(options);
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
           {
@@ -461,23 +443,23 @@
           {a: ['all work and no play make andybons a dull boy']},
         ];
 
-        return element.process(content, false).then(() => {
-          groups.shift(); // remove portedThreadsWithoutRangeGroup
+        const groups = await processor.process(content);
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
 
-          // group[0] is the file group
-          // group[1] is the "a" group
+        // 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');
-          }
-        });
+        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;
+      options.context = 10;
+      processor = new GrDiffProcessor(options);
       const content = [
         {a: ['all work and no play make andybons a dull boy']},
         {
@@ -494,8 +476,7 @@
         {a: ['all work and no play make andybons a dull boy']},
       ];
 
-      await element.process(content, false);
-
+      const groups = await processor.process(content);
       groups.shift(); // remove portedThreadsWithoutRangeGroup
 
       // group[0] is the file group
@@ -530,7 +511,8 @@
     });
 
     test('works with skip === 0', async () => {
-      element.context = 3;
+      options.context = 3;
+      processor = new GrDiffProcessor(options);
       const content = [
         {
           skip: 0,
@@ -546,14 +528,15 @@
           ],
         },
       ];
-      await element.process(content, false);
+      await processor.process(content);
     });
 
     test('break up common diff chunks', () => {
-      element.keyLocations = {
+      options.keyLocations = {
         left: {1: true},
         right: {10: true},
       };
+      processor = new GrDiffProcessor(options);
 
       const content = [
         {
@@ -574,7 +557,7 @@
           ],
         },
       ];
-      const result = element.splitCommonChunksWithKeyLocations(content);
+      const result = processor.splitCommonChunksWithKeyLocations(content);
       assert.deepEqual(result, [
         {
           ab: ['copy'],
@@ -602,8 +585,8 @@
         .fill(0)
         .map(() => `${Math.random()}`);
       const content = [{ab}];
-      element.context = -1;
-      const result = element.splitLargeChunks(content);
+      processor.context = FULL_CONTEXT;
+      const result = processor.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));
@@ -615,8 +598,8 @@
       const content = Array(size)
         .fill(0)
         .map(() => `${Math.random()}`);
-      element.context = 5;
-      const splitContent = element
+      processor.context = 5;
+      const splitContent = processor
         .splitLargeChunks([{a: [], b: content}])
         .map(r => r.b);
       assert.equal(splitContent.length, 3);
@@ -631,8 +614,8 @@
       const content = Array(size)
         .fill(0)
         .map(() => `${Math.random()}`);
-      element.context = 5;
-      const splitContent = element
+      processor.context = 5;
+      const splitContent = processor
         .splitLargeChunks([{a: content, b: []}])
         .map(r => r.a);
       assert.equal(splitContent.length, 3);
@@ -646,8 +629,8 @@
       const content = Array(size)
         .fill(0)
         .map(() => `${Math.random()}`);
-      element.context = 5;
-      const splitContent = element
+      processor.context = 5;
+      const splitContent = processor
         .splitLargeChunks([
           {
             a: content,
@@ -665,8 +648,8 @@
         .fill(0)
         .map(() => `${Math.random()}`);
       const content = [{ab}];
-      element.context = 4;
-      const result = element.splitCommonChunksWithKeyLocations(content);
+      processor.context = 4;
+      const result = processor.splitCommonChunksWithKeyLocations(content);
       assert.equal(result.length, 1);
       assert.deepEqual(result[0].ab, content[0].ab);
       assert.isFalse(result[0].keyLocation);
@@ -686,7 +669,7 @@
         [42, 26],
       ];
 
-      let results = element.convertIntralineInfos(content, highlights);
+      let results = processor.convertIntralineInfos(content, highlights);
       assert.deepEqual(results, [
         {
           contentIndex: 0,
@@ -703,7 +686,7 @@
           startIndex: 75,
         },
       ]);
-      const lines = element.linesFromRows(
+      const lines = processor.linesFromRows(
         GrDiffLineType.BOTH,
         content,
         0,
@@ -735,7 +718,7 @@
         [12, 67],
         [14, 29],
       ];
-      results = element.convertIntralineInfos(content, highlights);
+      results = processor.convertIntralineInfos(content, highlights);
       assert.deepEqual(results, [
         {
           contentIndex: 0,
@@ -766,7 +749,7 @@
 
       content = ['🙈 a', '🙉 b', '🙊 c'];
       highlights = [[2, 7]];
-      results = element.convertIntralineInfos(content, highlights);
+      results = processor.convertIntralineInfos(content, highlights);
       assert.deepEqual(results, [
         {
           contentIndex: 0,
@@ -784,25 +767,20 @@
       ]);
     });
 
-    test('isScrolling paused', () => {
+    test('isScrolling paused', async () => {
       const content = Array(200).fill({ab: ['', '']});
-      element.isScrolling = true;
-      element.process(content, false);
-      // Just the FILE and LOST groups.
-      assert.equal(groups.length, 2);
-    });
-
-    test('isScrolling unpaused', () => {
-      const content = Array(200).fill({ab: ['', '']});
-      element.isScrolling = false;
-      element.process(content, false);
-      // More groups have been processed. How many does not matter here.
+      processor.isScrolling = true;
+      const promise = processor.process(content);
+      processor.isScrolling = false;
+      const groups = await promise;
       assert.isAtLeast(groups.length, 3);
     });
 
-    test('image diffs', () => {
+    test('image diffs', async () => {
       const content = Array(200).fill({ab: ['', '']});
-      element.process(content, true);
+      options.isBinary = true;
+      processor = new GrDiffProcessor(options);
+      const groups = await processor.process(content);
       assert.equal(groups.length, 2);
 
       // Image diffs don't process content, just the 'FILE' line.
@@ -816,14 +794,14 @@
         rows = loremIpsum.split(' ');
       });
 
-      test('WHOLE_FILE', () => {
-        element.context = WHOLE_FILE;
+      test('FULL_CONTEXT', () => {
+        processor.context = FULL_CONTEXT;
         const state: State = {
           lineNums: {left: 10, right: 100},
           chunkIndex: 1,
         };
         const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
-        const result = element.processNext(state, chunks);
+        const result = processor.processNext(state, chunks);
 
         // Results in one, uncollapsed group with all rows.
         assert.equal(result.groups.length, 1);
@@ -850,8 +828,8 @@
         );
       });
 
-      test('WHOLE_FILE with skip chunks still get collapsed', () => {
-        element.context = WHOLE_FILE;
+      test('FULL_CONTEXT with skip chunks still get collapsed', () => {
+        processor.context = FULL_CONTEXT;
         const lineNums = {left: 10, right: 100};
         const state = {
           lineNums,
@@ -859,7 +837,7 @@
         };
         const skip = 10000;
         const chunks = [{a: ['foo']}, {skip}, {ab: rows}, {a: ['bar']}];
-        const result = element.processNext(state, chunks);
+        const result = processor.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);
@@ -893,21 +871,21 @@
       });
 
       test('with context', () => {
-        element.context = 10;
+        processor.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;
+        const result = processor.processNext(state, chunks);
+        const expectedCollapseSize = rows.length - 2 * processor.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);
+        assert.equal(result.groups[0].lines.length, processor.context);
+        assert.equal(result.groups[2].lines.length, processor.context);
 
         // The collapsed group has the hidden lines as its context group.
         assert.equal(
@@ -917,19 +895,19 @@
       });
 
       test('first', () => {
-        element.context = 10;
+        processor.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;
+        const result = processor.processNext(state, chunks);
+        const expectedCollapseSize = rows.length - processor.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);
+        assert.equal(result.groups[1].lines.length, processor.context);
 
         // The collapsed group has the hidden lines as its context group.
         assert.equal(
@@ -941,13 +919,13 @@
       test('few-rows', () => {
         // Only ten rows.
         rows = rows.slice(0, 10);
-        element.context = 10;
+        processor.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 result = processor.processNext(state, chunks);
 
         // Results in one uncollapsed group with all rows.
         assert.equal(result.groups.length, 1, 'Results in one group');
@@ -956,13 +934,13 @@
 
       test('no single line collapse', () => {
         rows = rows.slice(0, 7);
-        element.context = 3;
+        processor.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);
+        const result = processor.processNext(state, chunks);
 
         // Results in one uncollapsed group with all rows.
         assert.equal(result.groups.length, 1, 'Results in one group');
@@ -978,13 +956,13 @@
             lineNums: {left: 10, right: 100},
             chunkIndex: 0,
           };
-          element.context = 10;
+          processor.context = 10;
           chunks = [{ab: rows}, {ab: ['foo'], keyLocation: true}, {ab: rows}];
         });
 
         test('context before', () => {
           state.chunkIndex = 0;
-          const result = element.processNext(state, chunks);
+          const result = processor.processNext(state, chunks);
 
           // The first chunk is split into two groups:
           // 1) A context-control, hiding everything but the context before
@@ -995,14 +973,14 @@
           // The collapsed group has the hidden lines as its context group.
           assert.equal(
             result.groups[0].contextGroups[0].lines.length,
-            rows.length - element.context
+            rows.length - processor.context
           );
-          assert.equal(result.groups[1].lines.length, element.context);
+          assert.equal(result.groups[1].lines.length, processor.context);
         });
 
         test('key location itself', () => {
           state.chunkIndex = 1;
-          const result = element.processNext(state, chunks);
+          const result = processor.processNext(state, chunks);
 
           // The second chunk results in a single group, that is just the
           // line with the key location
@@ -1014,18 +992,18 @@
 
         test('context after', () => {
           state.chunkIndex = 2;
-          const result = element.processNext(state, chunks);
+          const result = processor.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);
+          assert.equal(result.groups[0].lines.length, processor.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
+            rows.length - processor.context
           );
         });
       });
@@ -1040,7 +1018,7 @@
 
       test('linesFromRows', () => {
         const startLineNum = 10;
-        let result = element.linesFromRows(
+        let result = processor.linesFromRows(
           GrDiffLineType.ADD,
           rows,
           startLineNum + 1
@@ -1057,7 +1035,7 @@
         );
         assert.notOk(result[result.length - 1].beforeNumber);
 
-        result = element.linesFromRows(
+        result = processor.linesFromRows(
           GrDiffLineType.REMOVE,
           rows,
           startLineNum + 1
@@ -1078,17 +1056,17 @@
 
     suite('breakdown*', () => {
       test('breakdownChunk breaks down additions', () => {
-        const breakdownSpy = sinon.spy(element, 'breakdown');
+        const breakdownSpy = sinon.spy(processor, 'breakdown');
         const chunk = {b: ['blah', 'blah', 'blah']};
-        const result = element.breakdownChunk(chunk);
+        const result = processor.breakdownChunk(chunk);
         assert.deepEqual(result, [chunk]);
         assert.isTrue(breakdownSpy.called);
       });
 
       test('breakdownChunk keeps due_to_rebase for broken down additions', () => {
-        sinon.spy(element, 'breakdown');
+        sinon.spy(processor, 'breakdown');
         const chunk = {b: ['blah', 'blah', 'blah'], due_to_rebase: true};
-        const result = element.breakdownChunk(chunk);
+        const result = processor.breakdownChunk(chunk);
         for (const subResult of result) {
           assert.isTrue(subResult.due_to_rebase);
         }
@@ -1100,7 +1078,7 @@
         );
         const size = 3;
 
-        const result = element.breakdown(array, size);
+        const result = processor.breakdown(array, size);
 
         for (const subResult of result) {
           assert.isAtMost(subResult.length, size);
@@ -1116,7 +1094,7 @@
         const size = 10;
         const expected = [array];
 
-        const result = element.breakdown(array, size);
+        const result = processor.breakdown(array, size);
 
         assert.deepEqual(result, expected);
       });
@@ -1126,7 +1104,7 @@
         const size = 10;
         const expected: string[][] = [];
 
-        const result = element.breakdown(array, size);
+        const result = processor.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
index a790736..f403ef9 100644
--- 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
@@ -18,6 +18,7 @@
   getSideByLineEl,
   isThreadEl,
 } from '../gr-diff/gr-diff-utils';
+import {getContentFromDiff} from '../../../utils/diff-util';
 
 /**
  * Possible CSS classes indicating the state of selection. Dynamically added/
@@ -34,15 +35,6 @@
   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;
@@ -50,9 +42,6 @@
   // visible for testing
   diffTable?: HTMLElement;
 
-  // visible for testing
-  linesCache: LinesCache = getNewCache();
-
   init(diff: DiffInfo, diffTable: HTMLElement) {
     this.cleanup();
     this.diff = diff;
@@ -60,7 +49,6 @@
     this.diffTable.classList.add(SelectionClass.RIGHT);
     this.diffTable.addEventListener('copy', this.handleCopy);
     this.diffTable.addEventListener('mousedown', this.handleDown);
-    this.linesCache = getNewCache();
   }
 
   cleanup() {
@@ -161,6 +149,7 @@
    * @return The selected text.
    */
   getSelectedText(side: Side) {
+    if (!this.diff) return '';
     const sel = this.getSelection();
     if (!sel || sel.rangeCount !== 1) {
       return ''; // No multi-select support yet.
@@ -188,7 +177,8 @@
       if (endLineDataValue) endLineNum = Number(endLineDataValue);
     }
 
-    return this.getRangeFromDiff(
+    return getContentFromDiff(
+      this.diff,
       startLineNum,
       range.startOffset,
       endLineNum,
@@ -196,52 +186,4 @@
       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
index f216e04..9cc6a90 100644
--- 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
@@ -123,6 +123,7 @@
   test('asks for text for left side Elements', () => {
     const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
     emulateCopyOn(diffTable.querySelector('div.contentText'));
+    assert.isTrue(getSelectedTextStub.called);
     assert.deepEqual([Side.LEFT], getSelectedTextStub.lastCall.args);
   });
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element.ts
new file mode 100644
index 0000000..b5a79cd
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element.ts
@@ -0,0 +1,448 @@
+/**
+ * @license
+ * Copyright 2023 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-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 '../gr-diff-builder/gr-diff-builder-image';
+import '../gr-diff-builder/gr-diff-section';
+import '../gr-diff-builder/gr-diff-row';
+import {
+  isResponsive,
+  FullContext,
+  diffClasses,
+  FULL_CONTEXT,
+} from './gr-diff-utils';
+import {ImageInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {
+  DiffViewMode,
+  Side,
+  createDefaultDiffPrefs,
+} from '../../../constants/constants';
+import {fire} from '../../../utils/event-util';
+import {RenderPreferences, LOST, DiffResponsiveMode} from '../../../api/diff';
+import {query, queryAll, state} from 'lit/decorators.js';
+import {html, LitElement, nothing} from 'lit';
+import {when} from 'lit/directives/when.js';
+import {classMap} from 'lit/directives/class-map.js';
+import {expandFileMode} from '../../../utils/file-util';
+import {
+  ColumnsToShow,
+  NO_COLUMNS,
+  diffModelToken,
+} from '../gr-diff-model/gr-diff-model';
+import {resolve} from '../../../models/dependency';
+import {getDiffLength, isImageDiff} from '../../../utils/diff-util';
+import {GrDiffGroup} from './gr-diff-group';
+import {subscribe} from '../../../elements/lit/subscription-controller';
+import {GrDiffSection} from '../gr-diff-builder/gr-diff-section';
+import {repeat} from 'lit/directives/repeat.js';
+
+const LARGE_DIFF_THRESHOLD_LINES = 10000;
+
+export class GrDiffElement extends LitElement {
+  @query('#diffTable')
+  diffTable?: HTMLTableElement;
+
+  @queryAll('gr-diff-section')
+  diffSections?: NodeListOf<GrDiffSection>;
+
+  @state() diff?: DiffInfo;
+
+  @state() baseImage?: ImageInfo;
+
+  @state() revisionImage?: ImageInfo;
+
+  @state() diffPrefs: DiffPreferencesInfo = createDefaultDiffPrefs();
+
+  @state() renderPrefs: RenderPreferences = {};
+
+  @state() viewMode = DiffViewMode.SIDE_BY_SIDE;
+
+  @state() groups: GrDiffGroup[] = [];
+
+  @state() showFullContext: FullContext = FullContext.UNDECIDED;
+
+  @state() errorMessage?: string;
+
+  @state() responsiveMode: DiffResponsiveMode = 'NONE';
+
+  @state() loading = true;
+
+  @state() columns: ColumnsToShow = NO_COLUMNS;
+
+  @state() columnCount = 0;
+
+  private getDiffModel = resolve(this, diffModelToken);
+
+  /**
+   * 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;
+  }
+
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getDiffModel().diff$,
+      diff => (this.diff = diff)
+    );
+    subscribe(
+      this,
+      () => this.getDiffModel().baseImage$,
+      baseImage => (this.baseImage = baseImage)
+    );
+    subscribe(
+      this,
+      () => this.getDiffModel().revisionImage$,
+      revisionImage => (this.revisionImage = revisionImage)
+    );
+    subscribe(
+      this,
+      () => this.getDiffModel().diffPrefs$,
+      diffPrefs => (this.diffPrefs = diffPrefs)
+    );
+    subscribe(
+      this,
+      () => this.getDiffModel().renderPrefs$,
+      renderPrefs => (this.renderPrefs = renderPrefs)
+    );
+    subscribe(
+      this,
+      () => this.getDiffModel().columnsToShow$,
+      columnsToShow => (this.columns = columnsToShow)
+    );
+    subscribe(
+      this,
+      () => this.getDiffModel().columnCount$,
+      columnCount => (this.columnCount = columnCount)
+    );
+    subscribe(
+      this,
+      () => this.getDiffModel().viewMode$,
+      viewMode => (this.viewMode = viewMode)
+    );
+    subscribe(
+      this,
+      () => this.getDiffModel().showFullContext$,
+      showFullContext => (this.showFullContext = showFullContext)
+    );
+    subscribe(
+      this,
+      () => this.getDiffModel().errorMessage$,
+      errorMessage => (this.errorMessage = errorMessage)
+    );
+    subscribe(
+      this,
+      () => this.getDiffModel().groups$,
+      groups => (this.groups = groups)
+    );
+    subscribe(
+      this,
+      () => this.getDiffModel().loading$,
+      loading => (this.loading = loading)
+    );
+    subscribe(
+      this,
+      () => this.getDiffModel().responsiveMode$,
+      responsiveMode => (this.responsiveMode = responsiveMode)
+    );
+  }
+
+  protected override async getUpdateComplete(): Promise<boolean> {
+    const result = await super.getUpdateComplete();
+    const sections = [...(this.diffSections ?? [])];
+    await Promise.all(sections.map(section => section.updateComplete));
+    return result;
+  }
+
+  protected override updated() {
+    if (this.diffSections?.length) {
+      this.fireRenderContent();
+    }
+  }
+
+  private async fireRenderContent() {
+    await this.updateComplete;
+    // TODO: Retire one of these two events.
+    fire(this, 'render-content', {});
+    fire(this, 'render', {});
+  }
+
+  override render() {
+    fire(this.diffTable, 'render-start', {});
+    return html`
+      ${this.renderHeader()} ${this.renderContainer()}
+      ${this.renderNewlineWarning()} ${this.renderLoadingError()}
+    `;
+  }
+
+  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.renderPrefs.can_comment,
+    };
+    const tableClasses = {
+      responsive: isResponsive(this.responsiveMode),
+    };
+    const isBinary = !!this.diff?.binary;
+    const isImage = isImageDiff(this.diff);
+    return html`
+      <div class=${classMap(cssClasses)}>
+        <table
+          id="diffTable"
+          class=${classMap(tableClasses)}
+          ?contenteditable=${this.isContentEditable}
+        >
+          ${this.renderColumns()}
+          ${when(!this.showWarning(), () =>
+            repeat(
+              this.groups,
+              group => group.id(),
+              group => this.renderSectionElement(group)
+            )
+          )}
+          ${when(isImage, () => this.renderImageDiff())}
+          ${when(!isImage && isBinary, () => this.renderBinaryDiff())}
+        </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>
+          `
+        )}
+        ${when(this.showWarning(), () => this.renderSizeWarning())}
+      </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 ${getDiffLength(this.diff)} 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 but used in tests.
+  showNoChangeMessage() {
+    return (
+      !this.loading &&
+      this.diff &&
+      !this.diff.binary &&
+      this.diffPrefs.ignore_whitespace !== 'IGNORE_NONE' &&
+      getDiffLength(this.diff) === 0
+    );
+  }
+
+  private showWarning() {
+    return (
+      this.diffPrefs?.context === FULL_CONTEXT &&
+      this.showFullContext === FullContext.UNDECIDED &&
+      getDiffLength(this.diff) >= LARGE_DIFF_THRESHOLD_LINES
+    );
+  }
+
+  // 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.getDiffModel().updateState({showFullContext: FullContext.YES});
+  }
+
+  private collapseContext() {
+    this.getDiffModel().updateState({showFullContext: FullContext.NO});
+  }
+
+  private computeNewlineWarning(): string | undefined {
+    const messages = [];
+    if (this.renderPrefs.show_newline_warning_left) {
+      messages.push('No newline at end of left file.');
+    }
+    if (this.renderPrefs.show_newline_warning_right) {
+      messages.push('No newline at end of right file.');
+    }
+    if (!messages.length) {
+      return undefined;
+    }
+    return messages.join(' \u2014 '); // \u2014 - '—'
+  }
+
+  private renderImageDiff() {
+    return when(
+      this.renderPrefs.use_new_image_diff_ui,
+      () => this.renderImageDiffNew(),
+      () => this.renderImageDiffOld()
+    );
+  }
+
+  private renderImageDiffNew() {
+    const autoBlink = !!this.renderPrefs?.image_diff_prefs?.automatic_blink;
+    return html`
+      <gr-diff-image-new
+        .automaticBlink=${autoBlink}
+        .baseImage=${this.baseImage ?? undefined}
+        .revisionImage=${this.revisionImage ?? undefined}
+        .columnCount=${this.columnCount}
+      ></gr-diff-image-new>
+    `;
+  }
+
+  private renderImageDiffOld() {
+    return html`
+      <gr-diff-image-old
+        .baseImage=${this.baseImage ?? undefined}
+        .revisionImage=${this.revisionImage ?? undefined}
+        .columnCount=${this.columnCount}
+      ></gr-diff-image-old>
+    `;
+  }
+
+  public renderBinaryDiff() {
+    return html`
+      <tbody class="gr-diff binary-diff">
+        <tr class="gr-diff">
+          <td colspan=${this.columnCount} class="gr-diff">
+            <span>Difference in binary files</span>
+          </td>
+        </tr>
+      </tbody>
+    `;
+  }
+
+  renderSectionElement(group: GrDiffGroup) {
+    const leftClass = `left-${group.startLine(Side.LEFT)}`;
+    const rightClass = `right-${group.startLine(Side.RIGHT)}`;
+    if (this.diff?.binary && group.startLine(Side.LEFT) === LOST) {
+      return nothing;
+    }
+    return html`
+      <gr-diff-section
+        class="${leftClass} ${rightClass}"
+        .group=${group}
+      ></gr-diff-section>
+    `;
+  }
+
+  renderColumns() {
+    const lineNumberWidth = getLineNumberCellWidth(
+      this.diffPrefs ?? createDefaultDiffPrefs()
+    );
+    return html`
+      <colgroup>
+        ${when(
+          this.columns.blame,
+          () => html`<col class=${diffClasses('blame')} />`
+        )}
+        ${when(
+          this.columns.leftNumber,
+          () =>
+            html`<col
+              class=${diffClasses(Side.LEFT)}
+              width=${lineNumberWidth}
+            />`
+        )}
+        ${when(
+          this.columns.leftSign,
+          () => html`<col class=${diffClasses(Side.LEFT, 'sign')} />`
+        )}
+        ${when(
+          this.columns.leftContent,
+          () => html`<col class=${diffClasses(Side.LEFT)} />`
+        )}
+        ${when(
+          this.columns.rightNumber,
+          () =>
+            html`<col
+              class=${diffClasses(Side.RIGHT)}
+              width=${lineNumberWidth}
+            />`
+        )}
+        ${when(
+          this.columns.rightSign,
+          () => html`<col class=${diffClasses(Side.RIGHT, 'sign')} />`
+        )}
+        ${when(
+          this.columns.rightContent,
+          () => html`<col class=${diffClasses(Side.RIGHT)} />`
+        )}
+      </colgroup>
+    `;
+  }
+}
+
+function getLineNumberCellWidth(prefs: DiffPreferencesInfo) {
+  return prefs.font_size * 4;
+}
+
+customElements.define('gr-diff-element', GrDiffElement);
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-element': GrDiffElement;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element_test.ts
new file mode 100644
index 0000000..be6b72e
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-element_test.ts
@@ -0,0 +1,3480 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {createDiff, createEmptyDiff} from '../../../test/test-data-generators';
+import './gr-diff-element';
+import {GrDiffElement} from './gr-diff-element';
+import {querySelectorAll} from '../../../utils/dom-util';
+import '@polymer/paper-button/paper-button';
+import {
+  DiffContent,
+  DiffInfo,
+  DiffPreferencesInfo,
+  DiffViewMode,
+  IgnoreWhitespaceType,
+} from '../../../api/diff';
+import {query, queryAndAssert, waitUntil} from '../../../test/test-utils';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {ImageInfo} from '../../../types/common';
+import {fixture, html, assert} from '@open-wc/testing';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {DiffModel, diffModelToken} from '../gr-diff-model/gr-diff-model';
+import {wrapInProvider} from '../../../models/di-provider-element';
+
+const DEFAULT_PREFS = createDefaultDiffPrefs();
+
+suite('gr-diff-element tests', () => {
+  let element: GrDiffElement;
+  let model: DiffModel;
+
+  const MINIMAL_PREFS: DiffPreferencesInfo = {
+    tab_size: 2,
+    line_length: 80,
+    font_size: 12,
+    context: 3,
+    ignore_whitespace: 'IGNORE_NONE',
+  };
+
+  setup(async () => {
+    model = new DiffModel(document);
+    element = (
+      await fixture(
+        wrapInProvider(
+          html`<gr-diff-element></gr-diff-element>`,
+          diffModelToken,
+          model
+        )
+      )
+    ).querySelector('gr-diff-element')!;
+  });
+
+  suite('rendering', () => {
+    test('empty diff', async () => {
+      await element.updateComplete;
+      assert.lightDom.equal(
+        element,
+        /* HTML */ `
+          <div class="diffContainer sideBySide">
+            <table id="diffTable">
+              <colgroup>
+                <col class="blame gr-diff" />
+                <col class="gr-diff left" width="48" />
+                <col class="gr-diff left" />
+                <col class="gr-diff right" width="48" />
+                <col class="gr-diff right" />
+              </colgroup>
+            </table>
+          </div>
+        `
+      );
+    });
+
+    test('a unified diff lit', async () => {
+      model.updateState({
+        diff: createDiff(),
+        diffPrefs: {...MINIMAL_PREFS},
+        renderPrefs: {view_mode: DiffViewMode.UNIFIED},
+      });
+      await element.updateComplete;
+      await waitForEventOnce(element, 'render');
+      assert.lightDom.equal(
+        element,
+        /* HTML */ `
+          <div class="diffContainer unified">
+            <table id="diffTable">
+              <colgroup>
+                <col class="blame gr-diff" />
+                <col class="gr-diff left" width="48" />
+                <col class="gr-diff right" width="48" />
+                <col class="gr-diff right" />
+              </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"
+                  ></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"
+                  ></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>
+                  </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>
+                  </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>
+                  </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>
+                  </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="blankLineNum 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>
+                  </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="blankLineNum 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>
+                  </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="blankLineNum 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>
+                  </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>
+                  </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>
+                  </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>
+                  </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>
+                  </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>
+                  </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="blankLineNum 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>
+                  </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="blankLineNum 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>
+                  </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="blankLineNum 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>
+                  </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="blankLineNum 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>
+                  </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="blankLineNum 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>
+                  </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="blankLineNum 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>
+                  </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="blankLineNum gr-diff right"></td>
+                  <td class="content gr-diff left remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-16"
+                    ></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="blankLineNum 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>
+                  </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>
+                  </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>
+                  </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>
+                  </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>
+                  </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>
+                  </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>
+                  </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="blankLineNum 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>
+                  </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="blankLineNum 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>
+                  </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="blankLineNum 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>
+                  </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="blankLineNum 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>
+                  </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>
+                  </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>
+                  </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>
+                  </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>
+                  </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>
+                  </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 lit', async () => {
+      model.updateState({
+        diff: createDiff(),
+        diffPrefs: {...MINIMAL_PREFS},
+        renderPrefs: {view_mode: DiffViewMode.SIDE_BY_SIDE},
+      });
+      await element.updateComplete;
+      await waitForEventOnce(element, 'render');
+      assert.lightDom.equal(
+        element,
+        /* HTML */ `
+          <div class="diffContainer sideBySide">
+            <table id="diffTable">
+              <colgroup>
+                <col class="blame gr-diff" />
+                <col class="gr-diff left" width="48" />
+                <col class="gr-diff left" />
+                <col class="gr-diff right" width="48" />
+                <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="both content gr-diff left lost no-intraline-info"
+                  ></td>
+                  <td class="gr-diff lineNum right" data-value="LOST"></td>
+                  <td
+                    class="both content gr-diff lost no-intraline-info right"
+                  ></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="both content file gr-diff left no-intraline-info"
+                  ></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"
+                  ></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="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-1"
+                    ></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="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-1"
+                    ></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="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-2"
+                    ></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="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-2"
+                    ></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="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-3"
+                    ></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="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-3"
+                    ></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="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-4"
+                    ></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="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-4"
+                    ></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">
+                    <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 content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-5"
+                    ></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">
+                    <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 content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-6"
+                    ></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">
+                    <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 content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-7"
+                    ></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="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-5"
+                    ></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="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-8"
+                    ></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="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-6"
+                    ></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="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-9"
+                    ></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="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-7"
+                    ></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="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-10"
+                    ></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="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-8"
+                    ></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="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-11"
+                    ></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="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-9"
+                    ></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="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-12"
+                    ></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="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-10"
+                    ></div>
+                  </td>
+                  <td class="blankLineNum gr-diff right"></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="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-11"
+                    ></div>
+                  </td>
+                  <td class="blankLineNum gr-diff right"></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="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-12"
+                    ></div>
+                  </td>
+                  <td class="blankLineNum gr-diff right"></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="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-13"
+                    ></div>
+                  </td>
+                  <td class="blankLineNum gr-diff right"></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="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-14"
+                    ></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 content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-13"
+                    ></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="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-15"
+                    ></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 content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-14"
+                    ></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="content gr-diff left remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-16"
+                    ></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 content gr-diff right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-15"
+                    ></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="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-17"
+                    ></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="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-16"
+                    ></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="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-18"
+                    ></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="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-17"
+                    ></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="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-19"
+                    ></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="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-18"
+                    ></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"></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="4">
+                    <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"></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 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="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-38"
+                    ></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="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-37"
+                    ></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="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-39"
+                    ></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="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-38"
+                    ></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="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-40"
+                    ></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="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-39"
+                    ></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">
+                    <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 content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-40"
+                    ></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">
+                    <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 content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-41"
+                    ></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">
+                    <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 content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-42"
+                    ></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">
+                    <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 content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-43"
+                    ></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="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-41"
+                    ></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="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-44"
+                    ></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="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-42"
+                    ></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="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-45"
+                    ></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="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-43"
+                    ></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="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-46"
+                    ></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="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-44"
+                    ></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="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-47"
+                    ></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="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-45"
+                    ></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="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-48"
+                    ></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('not logged in', () => {
+    setup(async () => {
+      await element.updateComplete;
+    });
+
+    suite('binary diffs', () => {
+      test('render binary diff', async () => {
+        model.updateState({
+          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,
+          },
+          diffPrefs: {...MINIMAL_PREFS},
+        });
+        await waitForEventOnce(element, 'render');
+
+        assert.lightDom.equal(
+          element,
+          /* HTML */ `
+            <div class="diffContainer sideBySide">
+              <gr-diff-section class="left-FILE right-FILE"> </gr-diff-section>
+              <gr-diff-row class="left-FILE right-FILE"> </gr-diff-row>
+              <table id="diffTable">
+                <colgroup>
+                  <col class="blame gr-diff" />
+                  <col class="gr-diff left" width="48" />
+                  <col class="gr-diff left" />
+                  <col class="gr-diff right" width="48" />
+                  <col class="gr-diff right" />
+                </colgroup>
+                <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="both content file gr-diff left no-intraline-info"
+                    ></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"
+                    ></td>
+                  </tr>
+                </tbody>
+                <tbody class="binary-diff gr-diff">
+                  <tr class="gr-diff">
+                    <td class="gr-diff" colspan="5">
+                      <span> Difference in binary files </span>
+                    </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.diffPrefs = {
+          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 () => {
+        model.updateState({
+          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,
+          },
+          diffPrefs: {...MINIMAL_PREFS},
+          renderPrefs: {view_mode: DiffViewMode.SIDE_BY_SIDE},
+          baseImage: mockFile1,
+          revisionImage: mockFile2,
+        });
+
+        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"> 1×1 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"> 1×1 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 () => {
+        model.updateState({
+          diff: {
+            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,
+          },
+          diffPrefs: {...MINIMAL_PREFS},
+          renderPrefs: {view_mode: DiffViewMode.SIDE_BY_SIDE},
+          baseImage: {...mockFile1, _name: 'carrot.jpg'},
+          revisionImage: {...mockFile1, _name: 'carrot2.jpg'},
+        });
+
+        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"> 1×1 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"> 1×1 image/bmp </span>
+            </label>
+          `
+        );
+      });
+
+      test('renders added image', async () => {
+        model.updateState({
+          diff: {
+            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,
+          },
+          diffPrefs: {...MINIMAL_PREFS},
+          renderPrefs: {view_mode: DiffViewMode.SIDE_BY_SIDE},
+          revisionImage: mockFile2,
+        });
+
+        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 () => {
+        model.updateState({
+          diff: {
+            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,
+          },
+          diffPrefs: {...MINIMAL_PREFS},
+          renderPrefs: {view_mode: DiffViewMode.SIDE_BY_SIDE},
+          baseImage: mockFile1,
+        });
+
+        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 () => {
+        model.updateState({
+          diff: {
+            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,
+          },
+          diffPrefs: {...MINIMAL_PREFS},
+          renderPrefs: {view_mode: DiffViewMode.SIDE_BY_SIDE},
+          baseImage: {...mockFile1, type: 'image/jpeg-evil'},
+        });
+
+        await waitForEventOnce(element, 'render');
+        const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+        const leftImage = query(imageDiffSection, 'td.left img');
+        assert.isNotOk(leftImage);
+      });
+    });
+  });
+
+  suite('diff header', () => {
+    setup(async () => {
+      model.updateState({
+        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}],
+        },
+        diffPrefs: {...MINIMAL_PREFS},
+        renderPrefs: {view_mode: DiffViewMode.SIDE_BY_SIDE},
+      });
+      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('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: GrDiffElement) => {
+      const warningElement = query(element, '.newlineWarning');
+      return warningElement?.textContent ?? '';
+    };
+
+    test('shows combined warning if both sides set to warn', async () => {
+      model.updateState({
+        renderPrefs: {
+          show_newline_warning_left: true,
+          show_newline_warning_right: true,
+        },
+      });
+      await element.updateComplete;
+      assert.include(
+        getWarning(element),
+        NO_NEWLINE_LEFT + ' \u2014 ' + NO_NEWLINE_RIGHT
+      ); // \u2014 - '—'
+    });
+
+    suite('showNewlineWarningLeft', () => {
+      test('show warning if true', async () => {
+        model.updateState({
+          renderPrefs: {show_newline_warning_left: true},
+        });
+        await element.updateComplete;
+        assert.include(getWarning(element), NO_NEWLINE_LEFT);
+      });
+
+      test('hide warning if false', async () => {
+        model.updateState({
+          renderPrefs: {show_newline_warning_left: false},
+        });
+        await element.updateComplete;
+        assert.notInclude(getWarning(element), NO_NEWLINE_LEFT);
+      });
+    });
+
+    suite('showNewlineWarningRight', () => {
+      test('show warning if true', async () => {
+        model.updateState({
+          renderPrefs: {show_newline_warning_right: true},
+        });
+        await element.updateComplete;
+        assert.include(getWarning(element), NO_NEWLINE_RIGHT);
+      });
+
+      test('hide warning if false', async () => {
+        model.updateState({
+          renderPrefs: {show_newline_warning_right: false},
+        });
+        await element.updateComplete;
+        assert.notInclude(getWarning(element), NO_NEWLINE_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;
+    const diffPrefs = {
+      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,
+    };
+    const diff: DiffInfo = {
+      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,
+    };
+    model.updateState({diff, diffPrefs});
+    await waitUntil(() => element.groups.length > 1);
+    await element.updateComplete;
+  };
+
+  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.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());
+    });
+  });
+
+  suite('rendering text, images and binary files', () => {
+    let content: DiffContent[] = [];
+
+    setup(() => {
+      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+      element.diffPrefs = {
+        ...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 () => {
+      model.updateState({diff: {...createEmptyDiff(), content}});
+      await waitUntil(() => element.groups.length > 2);
+      await element.updateComplete;
+      const bodies = [...(querySelectorAll(element, 'tbody') ?? [])];
+      assert.equal(bodies.length, 4);
+      assert.isTrue(bodies[0].innerHTML.includes('LOST'));
+      assert.isTrue(bodies[1].innerHTML.includes('FILE'));
+      assert.isTrue(bodies[2].innerHTML.includes('andybons a dull boy'));
+      assert.isTrue(bodies[3].innerHTML.includes('Non eram nescius'));
+    });
+
+    test('image', async () => {
+      model.updateState({
+        diff: {
+          ...createEmptyDiff(),
+          content,
+          binary: true,
+          meta_a: {name: 'carrot1.jpg', content_type: 'image/jpeg', lines: 0},
+          meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg', lines: 0},
+        },
+      });
+      await element.updateComplete;
+      const body = queryAndAssert(element, 'tbody.image-diff');
+      assert.lightDom.equal(
+        body,
+        /* HTML */ `
+          <label class="gr-diff">
+            <span class="gr-diff label"> No image </span>
+          </label>
+          <label class="gr-diff">
+            <span class="gr-diff label"> No image </span>
+          </label>
+        `
+      );
+    });
+
+    test('binary', async () => {
+      element.diff = {...createEmptyDiff(), content, binary: true};
+      await element.updateComplete;
+      const body = queryAndAssert(element, 'tbody.binary-diff');
+      assert.lightDom.equal(
+        body,
+        /* HTML */ '<span>Difference in binary files</span>'
+      );
+    });
+  });
+});
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
index 6d80d78..f406215 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
@@ -3,13 +3,10 @@
  * 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 {BLANK_LINE, GrDiffLine} from './gr-diff-line';
+import {GrDiffLineType, LineNumber, LineRange, Side} from '../../../api/diff';
 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. */
@@ -133,12 +130,10 @@
     for (const line of group.lines) {
       if (
         (line.beforeNumber &&
-          line.beforeNumber !== 'FILE' &&
-          line.beforeNumber !== 'LOST' &&
+          typeof line.beforeNumber === 'number' &&
           line.beforeNumber < leftSplit) ||
         (line.afterNumber &&
-          line.afterNumber !== 'FILE' &&
-          line.afterNumber !== 'LOST' &&
+          typeof line.afterNumber === 'number' &&
           line.afterNumber < rightSplit)
       ) {
         before.push(line);
@@ -321,11 +316,6 @@
    */
   readonly keyLocation: boolean = false;
 
-  /**
-   * Once rendered the diff builder sets this to the diff section element.
-   */
-  element?: HTMLElement;
-
   readonly lines: GrDiffLine[] = [];
 
   readonly adds: GrDiffLine[] = [];
@@ -435,7 +425,7 @@
   }
 
   containsLine(side: Side, line: LineNumber) {
-    if (line === 'FILE' || line === 'LOST') {
+    if (typeof line !== 'number') {
       // For FILE and LOST, beforeNumber and afterNumber are the same
       return this.lines[0]?.beforeNumber === line;
     }
@@ -462,14 +452,8 @@
   }
 
   private _updateRangeWithNewLine(line: GrDiffLine) {
-    if (
-      line.beforeNumber === 'FILE' ||
-      line.afterNumber === 'FILE' ||
-      line.beforeNumber === 'LOST' ||
-      line.afterNumber === 'LOST'
-    ) {
-      return;
-    }
+    if (typeof line.beforeNumber !== 'number') return;
+    if (typeof line.afterNumber !== 'number') return;
 
     if (line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.BOTH) {
       if (
@@ -499,23 +483,6 @@
     }
   }
 
-  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 !== undefined ||
-      lineNumber === 'LOST' ||
-      lineNumber === 'FILE' ||
-      this.type === GrDiffGroupType.CONTEXT_CONTROL
-    ) {
-      return Promise.resolve();
-    }
-    assertIsDefined(this.element);
-    await (this.element as LitElement).updateComplete;
-    await untilRendered(this.element.firstElementChild as HTMLElement);
-  }
-
   /**
    * Determines whether the group is either totally an addition or totally
    * a removal.
@@ -527,4 +494,10 @@
       !(!this.adds.length && !this.removes.length)
     );
   }
+
+  id() {
+    return `${this.type} ${this.startLine(Side.LEFT)}  ${this.startLine(
+      Side.RIGHT
+    )}`;
+  }
 }
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
index 7ead68f..bbbb4ad 100644
--- 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
@@ -4,14 +4,14 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../test/common-test-setup';
-import {GrDiffLine, GrDiffLineType, BLANK_LINE} from './gr-diff-line';
+import {GrDiffLine, 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';
+import {FILE, GrDiffLineType, LOST, Side} from '../../../api/diff';
 
 suite('gr-diff-group tests', () => {
   test('delta line pairs', () => {
@@ -297,18 +297,18 @@
 
     test('FILE', () => {
       const lines: GrDiffLine[] = [];
-      lines.push(new GrDiffLine(GrDiffLineType.BOTH, 'FILE', 'FILE'));
+      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');
+      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'));
+      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');
+      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
index 338a275..1a89207 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts
@@ -4,17 +4,13 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {
+  FILE,
   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,
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
index e7f4b51..c717c47 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
@@ -6,16 +6,6 @@
 import {css} from 'lit';
 
 export const grDiffStyles = 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;
   }
@@ -46,7 +36,8 @@
     border-collapse: collapse;
     table-layout: fixed;
   }
-  td.lineNum {
+  td.lineNum,
+  td.blankLineNum {
     /* Enforces background whenever lines wrap */
     background-color: var(--diff-blank-background-color);
   }
@@ -375,7 +366,7 @@
 
   /* Context controls */
   .contextControl {
-    display: var(--context-control-display, table-row-group);
+    display: table-row-group;
     background-color: transparent;
     border: none;
     --divider-height: var(--spacing-s);
@@ -404,6 +395,16 @@
     height: calc(var(--line-height-normal) + var(--spacing-s));
   }
 
+  /* Hide the actual context control buttons */
+  :host(.disable-context-control-buttons) .contextControl gr-context-controls {
+    display: none;
+  }
+  /* Maintain a small amount of padding at the edges of diff chunks */
+  :host(.disable-context-control-buttons) .contextControl .contextBackground {
+    height: var(--spacing-s);
+    border-right: none;
+  }
+
   .dividerCell {
     vertical-align: top;
   }
@@ -461,9 +462,11 @@
     color: var(--link-color);
     padding: var(--spacing-m) 0 var(--spacing-m) 48px;
   }
-  #diffTable {
+  gr-diff-element {
     /* for gr-selection-action-box positioning */
     position: relative;
+    /* Firefox requires a block to position child elements absolutely */
+    display: block;
   }
   #diffTable:focus {
     outline: none;
@@ -498,18 +501,6 @@
     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;
   }
@@ -658,7 +649,7 @@
   gr-selection-action-box {
     /* Needs z-index to appear above wrapped content, since it's inserted
        into DOM before it. */
-    z-index: 10;
+    z-index: 120;
   }
 
   gr-diff-image-new,
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
index 669537e..219f16e 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
@@ -3,16 +3,19 @@
  * 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 {CommentRange} from '../../../types/common';
+import {Side, SpecialFilePath} from '../../../constants/constants';
 import {
+  DiffContextExpandedExternalDetail,
   DiffPreferencesInfo,
   DiffResponsiveMode,
+  DisplayLine,
+  FILE,
+  LOST,
+  LineNumber,
   RenderPreferences,
 } from '../../../api/diff';
-import {getBaseUrl} from '../../../utils/url-util';
+import {GrDiffGroup} from './gr-diff-group';
 
 /**
  * In JS, unicode code points above 0xFFFF occupy two elements of a string.
@@ -36,24 +39,6 @@
  */
 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
@@ -103,9 +88,7 @@
 }
 
 export function lineNumberToNumber(lineNumber?: LineNumber | null): number {
-  if (!lineNumber) return 0;
-  if (lineNumber === 'LOST') return 0;
-  if (lineNumber === 'FILE') return 0;
+  if (typeof lineNumber !== 'number') return 0;
   return lineNumber;
 }
 
@@ -138,15 +121,15 @@
   const lineNumberStr = lineEl.getAttribute('data-value');
   if (!lineNumberStr) return null;
   if (lineNumberStr === FILE) return FILE;
-  if (lineNumberStr === 'LOST') return 'LOST';
+  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;
+  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}`);
@@ -172,37 +155,167 @@
   return range;
 }
 
+/**
+ * This is all the data that gr-diff extracts from comment thread elements,
+ * see `GrDiffThreadElement`. Otherwise gr-diff treats such elements as a black
+ * box.
+ */
+export interface GrDiffCommentThread {
+  side: Side;
+  line: LineNumber;
+  range?: CommentRange;
+  rootId?: string;
+}
+
+/**
+ * Retrieves all the data from a comment thread element that the gr-diff API
+ * contract defines for such elements.
+ */
+export function getDataFromCommentThreadEl(
+  threadEl?: EventTarget | null
+): GrDiffCommentThread | undefined {
+  if (!isThreadEl(threadEl)) return undefined;
+  const side = getSide(threadEl);
+  const line = getLine(threadEl);
+  const range = getRange(threadEl);
+  if (!side) return undefined;
+  if (!line) return undefined;
+  return {side, line, range, rootId: threadEl.rootId};
+}
+
+export interface KeyLocations {
+  left: {[key: string]: boolean};
+  right: {[key: string]: boolean};
+}
+
+/**
+ * "Context" is the number of lines that we are showing around diff chunks and
+ * commented lines. This typically comes from a user preference and is set to
+ * something like 3 or 10.
+ *
+ * `FULL_CONTEXT` means that the user wants to see the entire file. We could
+ * also call this "infinite context".
+ */
+export const FULL_CONTEXT = -1;
+
+export enum FullContext {
+  /** User has opted into showing the full context. */
+  YES = 'YES',
+  /** User has opted into showing only limited context. */
+  NO = 'NO',
+  /**
+   * User has not decided yet. Will see a warning message with two options then,
+   * if the file is too large.
+   */
+  UNDECIDED = 'UNDECIDED',
+}
+
+export function computeContext(
+  prefsContext: number | undefined,
+  showFullContext: FullContext,
+  defaultContext: number
+) {
+  if (showFullContext === FullContext.YES) {
+    return FULL_CONTEXT;
+  }
+  if (
+    prefsContext !== undefined &&
+    !(showFullContext === FullContext.NO && prefsContext === FULL_CONTEXT)
+  ) {
+    return prefsContext;
+  }
+  return defaultContext;
+}
+
+export function computeLineLength(
+  prefs: DiffPreferencesInfo,
+  path: string | undefined
+): number {
+  if (path === SpecialFilePath.COMMIT_MESSAGE) {
+    return 72;
+  }
+  const lineLength = prefs.line_length;
+  if (Number.isInteger(lineLength) && lineLength > 0) {
+    return lineLength;
+  }
+  return 100;
+}
+
+export function computeKeyLocations(
+  lineOfInterest: DisplayLine | undefined,
+  comments: GrDiffCommentThread[]
+) {
+  const keyLocations: KeyLocations = {left: {}, right: {}};
+
+  if (lineOfInterest) {
+    keyLocations[lineOfInterest.side][lineOfInterest.lineNum] = true;
+  }
+
+  for (const comment of comments) {
+    keyLocations[comment.side][comment.line] = true;
+    if (comment.range?.start_line) {
+      keyLocations[comment.side][comment.range.start_line] = true;
+    }
+  }
+
+  return keyLocations;
+}
+
+export function compareComments(
+  c1: GrDiffCommentThread,
+  c2: GrDiffCommentThread
+): number {
+  if (c1.side !== c2.side) {
+    return c1.side === Side.RIGHT ? 1 : -1;
+  }
+
+  if (c1.line !== c2.line) {
+    if (c1.line === FILE && c2.line !== FILE) return -1;
+    if (c1.line !== FILE && c2.line === FILE) return 1;
+    if (c1.line === LOST && c2.line !== LOST) return -1;
+    if (c1.line !== LOST && c2.line === LOST) return 1;
+    return (c1.line as number) - (c2.line as number);
+  }
+
+  if (c1.rootId !== c2.rootId) {
+    if (!c1.rootId) return -1;
+    if (!c2.rootId) return 1;
+    return c1.rootId > c2.rootId ? 1 : -1;
+  }
+
+  if (c1.range && c2.range) {
+    const r1 = JSON.stringify(c1.range);
+    const r2 = JSON.stringify(c2.range);
+    return r1 > r2 ? 1 : -1;
+  }
+  if (c1.range) return 1;
+  if (c2.range) return -1;
+
+  return 0;
+}
+
 // 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'.
+// have, e.g. 'diff-side', 'range' (optional), 'line-num'.
+// Comment widgets are also required to have `comment-thread` in their css
+// class list.
 export interface GrDiffThreadElement extends HTMLElement {
   rootId: string;
 }
 
-export function isThreadEl(node: Node): node is GrDiffThreadElement {
+export function isThreadEl(
+  node?: Node | EventTarget | null
+): node is GrDiffThreadElement {
   return (
-    node.nodeType === Node.ELEMENT_NODE &&
+    !!node &&
+    (node as 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.
  */
@@ -342,56 +455,10 @@
   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);
+export interface DiffContextExpandedEventDetail
+  extends DiffContextExpandedExternalDetail {
+  /** The context control group that should be replaced by `groups`. */
+  contextGroup: GrDiffGroup;
+  groups: GrDiffGroup[];
+  numLines: number;
 }
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
index 2438bcb..f425e2b 100644
--- 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
@@ -4,16 +4,24 @@
  * 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,
   getRange,
+  computeKeyLocations,
+  GrDiffCommentThread,
+  getDataFromCommentThreadEl,
+  compareComments,
+  GrDiffThreadElement,
+  computeContext,
+  FULL_CONTEXT,
+  FullContext,
+  computeLineLength,
 } from './gr-diff-utils';
+import {FILE, LOST, Side} from '../../../api/diff';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
 
 const LINE_BREAK_HTML = '<span class="gr-diff br"></span>';
 
@@ -165,38 +173,6 @@
     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);
-  });
-
   test('getRange returns undefined with start_line = 0', () => {
     const range = {
       start_line: 0,
@@ -212,4 +188,206 @@
     threadEl.setAttribute('slot', 'right-1');
     assert.isUndefined(getRange(threadEl));
   });
+
+  suite('computeContext', () => {
+    test('computeContext 1', () => {
+      assert.equal(computeContext(1, FullContext.YES, 2), FULL_CONTEXT);
+      assert.equal(computeContext(1, FullContext.NO, 2), 1);
+      assert.equal(computeContext(1, FullContext.UNDECIDED, 2), 1);
+    });
+
+    test('computeContext 0', () => {
+      assert.equal(computeContext(0, FullContext.YES, 2), FULL_CONTEXT);
+      assert.equal(computeContext(0, FullContext.NO, 2), 0);
+      assert.equal(computeContext(0, FullContext.UNDECIDED, 2), 0);
+    });
+
+    test('computeContext FULL_CONTEXT', () => {
+      assert.equal(
+        computeContext(FULL_CONTEXT, FullContext.YES, 2),
+        FULL_CONTEXT
+      );
+      assert.equal(computeContext(FULL_CONTEXT, FullContext.NO, 2), 2);
+      assert.equal(
+        computeContext(FULL_CONTEXT, FullContext.UNDECIDED, 2),
+        FULL_CONTEXT
+      );
+    });
+  });
+
+  suite('computeLineLength', () => {
+    test('computeLineLength(1, ...)', () => {
+      assert.equal(
+        computeLineLength(
+          {...createDefaultDiffPrefs(), line_length: 1},
+          'a.txt'
+        ),
+        1
+      );
+      assert.equal(
+        computeLineLength(
+          {...createDefaultDiffPrefs(), line_length: 1},
+          undefined
+        ),
+        1
+      );
+    });
+
+    test('computeLineLength(1, "/COMMIT_MSG")', () => {
+      assert.equal(
+        computeLineLength(
+          {...createDefaultDiffPrefs(), line_length: 1},
+          '/COMMIT_MSG'
+        ),
+        72
+      );
+    });
+  });
+
+  suite('key locations', () => {
+    test('lineOfInterest is a key location', () => {
+      const lineOfInterest = {lineNum: 789, side: Side.LEFT};
+      assert.deepEqual(computeKeyLocations(lineOfInterest, []), {
+        left: {789: true},
+        right: {},
+      });
+    });
+
+    test('line comments are key locations', async () => {
+      const comments: GrDiffCommentThread[] = [{side: Side.RIGHT, line: 3}];
+      assert.deepEqual(computeKeyLocations(undefined, comments), {
+        left: {},
+        right: {3: true},
+      });
+    });
+
+    test('file comments are key locations', async () => {
+      const comments: GrDiffCommentThread[] = [{side: Side.LEFT, line: FILE}];
+      assert.deepEqual(computeKeyLocations(undefined, comments), {
+        left: {FILE: true},
+        right: {},
+      });
+    });
+
+    test('lots of key locations', () => {
+      const lineOfInterest = {lineNum: 789, side: Side.LEFT};
+      const comments: GrDiffCommentThread[] = [
+        {side: Side.LEFT, line: FILE},
+        {side: Side.LEFT, line: 2},
+        {side: Side.LEFT, line: 111},
+        {side: Side.RIGHT, line: LOST},
+        {side: Side.RIGHT, line: 13},
+        {side: Side.RIGHT, line: 19},
+      ];
+      assert.deepEqual(computeKeyLocations(lineOfInterest, comments), {
+        left: {FILE: true, 2: true, 111: true, 789: true},
+        right: {LOST: true, 13: true, 19: true},
+      });
+    });
+  });
+
+  suite('toCommentThreadModel', () => {
+    test('simple example', () => {
+      const el = document.createElement(
+        'div'
+      ) as unknown as GrDiffThreadElement;
+      el.className = 'comment-thread';
+      el.setAttribute('diff-side', 'left');
+      el.setAttribute('line-num', '3');
+      el.rootId = 'ab12';
+
+      assert.deepEqual(getDataFromCommentThreadEl(el), {
+        line: 3,
+        side: Side.LEFT,
+        range: undefined,
+        rootId: 'ab12',
+      });
+    });
+
+    test('FILE default', () => {
+      const el = document.createElement(
+        'div'
+      ) as unknown as GrDiffThreadElement;
+      el.className = 'comment-thread';
+      el.setAttribute('diff-side', 'left');
+      el.rootId = 'ab12';
+
+      assert.deepEqual(getDataFromCommentThreadEl(el), {
+        line: FILE,
+        side: Side.LEFT,
+        range: undefined,
+        rootId: 'ab12',
+      });
+    });
+
+    test('undefined', () => {
+      const el = document.createElement(
+        'div'
+      ) as unknown as GrDiffThreadElement;
+      assert.isUndefined(getDataFromCommentThreadEl(el));
+      el.className = 'comment-thread';
+      assert.isUndefined(getDataFromCommentThreadEl(el));
+      el.setAttribute('line-num', '3');
+      assert.isUndefined(getDataFromCommentThreadEl(el));
+    });
+  });
+
+  suite('compare comments', () => {
+    test('sort array of comments', () => {
+      const comments: GrDiffCommentThread[] = [
+        {side: Side.RIGHT, line: 3},
+        {side: Side.RIGHT, line: 2},
+        {side: Side.RIGHT, line: 1},
+        {side: Side.RIGHT, line: LOST},
+        {side: Side.RIGHT, line: FILE},
+        {side: Side.LEFT, line: 3},
+        {side: Side.LEFT, line: 2},
+        {
+          side: Side.LEFT,
+          line: 1,
+          rootId: 'b',
+          range: {
+            start_line: 1,
+            start_character: 0,
+            end_line: 5,
+            end_character: 14,
+          },
+        },
+        {
+          side: Side.LEFT,
+          line: 1,
+          rootId: 'b',
+          range: {
+            start_line: 1,
+            start_character: 0,
+            end_line: 2,
+            end_character: 4,
+          },
+        },
+        {side: Side.LEFT, line: 1, rootId: 'b'},
+        {side: Side.LEFT, line: 1, rootId: 'a'},
+        {side: Side.LEFT, line: 1},
+        {side: Side.LEFT, line: LOST},
+      ];
+      const commentsOrdered: GrDiffCommentThread[] = [
+        comments[12],
+        comments[11],
+        comments[10],
+        comments[9],
+        comments[8],
+        comments[7],
+        comments[6],
+        comments[5],
+        comments[4],
+        comments[3],
+        comments[2],
+        comments[1],
+        comments[0],
+      ];
+      assert.sameOrderedMembers(
+        comments.sort(compareComments),
+        commentsOrdered
+      );
+    });
+  });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index 3929330..a0b579a 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -6,81 +6,77 @@
 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 '../gr-diff-builder/gr-diff-builder-image';
+import '../gr-diff-builder/gr-diff-section';
+import './gr-diff-element';
+import '../gr-diff-builder/gr-diff-row';
 import {
-  getLine,
-  getLineElByChild,
   getLineNumber,
-  getRange,
-  getSide,
-  GrDiffThreadElement,
-  isLongCommentRange,
   isThreadEl,
-  rangesEqual,
   getResponsiveMode,
   isResponsive,
-  getDiffLength,
-} from './gr-diff-utils';
+  getSideByLineEl,
+  compareComments,
+  getDataFromCommentThreadEl,
+  FullContext,
+  DiffContextExpandedEventDetail,
+  GrDiffCommentThread,
+} from '../gr-diff/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 {CoverageRange, DiffLayer, isDefined} from '../../../types/types';
 import {
-  CreateRangeCommentEventDetail,
-  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';
+  CommentRangeLayer,
+  GrRangedCommentLayer,
+} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
+import {DiffViewMode, Side} from '../../../constants/constants';
 import {fire, fireAlert} 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,
+  LineNumber,
+  ContentLoadNeededEventDetail,
+  DiffContextExpandedExternalDetail,
 } from '../../../api/diff';
-import {isSafari, toggleClass} from '../../../utils/dom-util';
+import {isSafari} 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 {property, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
-import {html, LitElement, nothing, PropertyValues} from 'lit';
-import {when} from 'lit/directives/when.js';
+import {html, LitElement, PropertyValues} from 'lit';
 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';
 import {DiffModel, diffModelToken} from '../gr-diff-model/gr-diff-model';
 import {provide} from '../../../models/dependency';
 import {grDiffStyles} from './gr-diff-styles';
+import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer';
+import {
+  GrAnnotationImpl,
+  getStringLength,
+} from '../gr-diff-highlight/gr-annotation';
+import {
+  GrDiffGroup,
+  GrDiffGroupType,
+  hideInContextControl,
+} from './gr-diff-group';
+import {GrDiffLine} from './gr-diff-line';
+import {subscribe} from '../../../elements/lit/subscription-controller';
+import {GrDiffSection} from '../gr-diff-builder/gr-diff-section';
+import {GrDiffRow} from '../gr-diff-builder/gr-diff-row';
+import {GrDiffElement} from './gr-diff-element';
 
-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 TRAILING_WHITESPACE_PATTERN = /\s+$/;
 
 const COMMIT_MSG_PATH = '/COMMIT_MSG';
 /**
@@ -92,11 +88,6 @@
  */
 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.
@@ -131,8 +122,17 @@
    * @event diff-context-expanded
    */
 
-  @query('#diffTable')
-  diffTable?: HTMLTableElement;
+  /**
+   * Deprecated. Use `diffElement` instead.
+   *
+   * TODO: Migrate to new diff. Remove dependency on this property from external
+   * gr-diff users that instantiate TokenHighlightLayer.
+   */
+  @query('gr-diff-element')
+  diffTable?: HTMLElement;
+
+  @query('gr-diff-element')
+  diffElement?: GrDiffElement;
 
   @property({type: Boolean})
   noAutoRender = false;
@@ -146,19 +146,12 @@
   @property({type: Object})
   renderPrefs: RenderPreferences = {};
 
-  @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;
@@ -169,6 +162,7 @@
   @property({type: Boolean})
   lineWrapping = false;
 
+  // TODO: Migrate users to using the same property in render preferences.
   @property({type: String})
   viewMode = DiffViewMode.SIDE_BY_SIDE;
 
@@ -199,9 +193,6 @@
   @property({type: Object})
   diff?: DiffInfo;
 
-  @state()
-  private diffTableClass = '';
-
   @property({type: Object})
   baseImage?: ImageInfo;
 
@@ -216,33 +207,21 @@
   @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;
 
+  // TODO: Migrate users to using the same property in render preferences.
   @property({type: Boolean})
   showNewlineWarningLeft = false;
 
+  // TODO: Migrate users to using the same property in render preferences.
   @property({type: Boolean})
   showNewlineWarningRight = false;
 
+  // TODO: Migrate users to using the same property in render preferences.
   @property({type: Boolean})
   useNewImageDiffUi = false;
 
@@ -250,18 +229,9 @@
   @state()
   diffLength?: number;
 
-  /**
-   * Observes comment nodes added or removed at any point.
-   * Can be used to unregister upon detachment.
-   */
+  /** Observes comment nodes added or removed at any point. */
   private nodeObserver?: MutationObserver;
 
-  @property({type: Array})
-  layers?: DiffLayer[];
-
-  // Private but used in tests.
-  renderDiffTableTask?: DelayedPromise<void>;
-
   // Private but used in tests.
   diffSelection = new GrDiffSelection();
 
@@ -269,9 +239,36 @@
   highlights = new GrDiffHighlight();
 
   // Private but used in tests.
-  diffBuilder = new GrDiffBuilderElement();
+  diffModel = new DiffModel(this);
 
-  private diffModel = new DiffModel(undefined);
+  /**
+   * Just the layers that are passed in from the outside. Will be joined with
+   * `layersInternal` and sent to the diff model.
+   */
+  @property({type: Array})
+  layers: DiffLayer[] = [];
+
+  /**
+   * Just the internal default layers. See `layers` for the property that can
+   * be set from the outside.
+   */
+  private layersInternal: DiffLayer[] = [];
+
+  private coverageLayerLeft = new GrCoverageLayer(Side.LEFT);
+
+  private coverageLayerRight = new GrCoverageLayer(Side.RIGHT);
+
+  private rangeLayer = new GrRangedCommentLayer();
+
+  @state() groups: GrDiffGroup[] = [];
+
+  @state() private context = 3;
+
+  private readonly layerUpdateListener: (
+    start: LineNumber,
+    end: LineNumber,
+    side: Side
+  ) => void;
 
   static override get styles() {
     return [
@@ -286,15 +283,29 @@
   constructor() {
     super();
     provide(this, diffModelToken, () => this.diffModel);
-    this.addEventListener(
-      'create-range-comment',
-      (e: CustomEvent<CreateRangeCommentEventDetail>) =>
-        this.handleCreateRangeComment(e)
+    subscribe(
+      this,
+      () => this.diffModel.context$,
+      context => (this.context = context)
     );
-    this.addEventListener('render-content', () => this.handleRenderContent());
+    subscribe(
+      this,
+      () => this.diffModel.groups$,
+      groups => (this.groups = groups)
+    );
     this.addEventListener('moved-link-clicked', (e: MovedLinkClickedEvent) => {
-      this.dispatchSelectedLine(e.detail.lineNum, e.detail.side);
+      this.diffModel.selectLine(e.detail.lineNum, e.detail.side);
     });
+    this.addEventListener(
+      'diff-context-expanded-internal-new',
+      this.onDiffContextExpanded
+    );
+    this.layerUpdateListener = (
+      start: LineNumber,
+      end: LineNumber,
+      side: Side
+    ) => this.requestRowUpdates(start, end, side);
+    this.layersInternalInit();
   }
 
   override connectedCallback() {
@@ -302,26 +313,73 @@
     if (this.loggedIn) {
       this.addSelectionListeners();
     }
-    if (this.diff && this.diffTable) {
-      this.diffSelection.init(this.diff, this.diffTable);
+    if (this.diff && this.diffElement) {
+      this.diffSelection.init(this.diff, this.diffElement);
     }
-    if (this.diffTable && this.diffBuilder) {
-      this.highlights.init(this.diffTable, this.diffBuilder);
+    if (this.diffElement) {
+      this.highlights.init(this.diffElement, this);
     }
-    this.diffBuilder.init();
+    this.observeNodes();
   }
 
   override disconnectedCallback() {
+    if (this.nodeObserver) {
+      this.nodeObserver.disconnect();
+      this.nodeObserver = undefined;
+    }
     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('diff') ||
+      changedProperties.has('path') ||
+      changedProperties.has('renderPrefs') ||
+      changedProperties.has('viewMode') ||
+      changedProperties.has('loggedIn') ||
+      changedProperties.has('useNewImageDiffUi') ||
+      changedProperties.has('showNewlineWarningLeft') ||
+      changedProperties.has('showNewlineWarningRight') ||
+      changedProperties.has('prefs') ||
+      changedProperties.has('lineOfInterest')
+    ) {
+      if (this.diff && this.prefs) {
+        const renderPrefs = {...(this.renderPrefs ?? {})};
+        // TODO: Migrate users to using render preferences directly. Then removes these overrides.
+        if (renderPrefs.view_mode === undefined) {
+          renderPrefs.view_mode = this.viewMode;
+        }
+        if (renderPrefs.can_comment === undefined) {
+          renderPrefs.can_comment = this.loggedIn;
+        }
+        if (renderPrefs.use_new_image_diff_ui === undefined) {
+          renderPrefs.use_new_image_diff_ui = this.useNewImageDiffUi;
+        }
+        if (renderPrefs.show_newline_warning_left === undefined) {
+          renderPrefs.show_newline_warning_left = this.showNewlineWarningLeft;
+        }
+        if (renderPrefs.show_newline_warning_right === undefined) {
+          renderPrefs.show_newline_warning_right = this.showNewlineWarningRight;
+        }
+        this.diffModel.updateState({
+          diff: this.diff,
+          path: this.path,
+          renderPrefs,
+          diffPrefs: this.prefs,
+          lineOfInterest: this.lineOfInterest,
+        });
+      }
+    }
+    if (changedProperties.has('baseImage')) {
+      this.diffModel.updateState({baseImage: this.baseImage});
+    }
+    if (changedProperties.has('revisionImage')) {
+      this.diffModel.updateState({revisionImage: this.revisionImage});
+    }
+    if (
       changedProperties.has('path') ||
       changedProperties.has('lineWrapping') ||
       changedProperties.has('viewMode') ||
@@ -330,6 +388,9 @@
     ) {
       this.prefsChanged();
     }
+    if (changedProperties.has('layers')) {
+      this.layersChanged();
+    }
     if (changedProperties.has('blame')) {
       this.blameChanged();
     }
@@ -344,93 +405,33 @@
       }
     }
     if (changedProperties.has('coverageRanges')) {
-      this.diffBuilder.updateCoverageRanges(this.coverageRanges);
+      this.updateCoverageRanges(this.coverageRanges);
     }
     if (changedProperties.has('lineOfInterest')) {
       this.lineOfInterestChanged();
     }
   }
 
-  protected override updated(changedProperties: PropertyValues<this>): void {
+  protected override async getUpdateComplete(): Promise<boolean> {
+    const result = await super.getUpdateComplete();
+    await this.diffElement?.updateComplete;
+    return result;
+  }
+
+  protected override updated(changedProperties: PropertyValues<this>) {
     if (changedProperties.has('diff')) {
-      // diffChanged relies on diffTable ahving been rendered.
+      // diffChanged relies on diffElement having been rendered.
       this.diffChanged();
     }
+    if (changedProperties.has('groups')) {
+      if (this.groups?.length > 0) {
+        this.loading = false;
+      }
+    }
   }
 
   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,
-    };
-    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>
-    `;
+    return html`<gr-diff-element></gr-diff-element>`;
   }
 
   private addSelectionListeners() {
@@ -443,22 +444,6 @@
     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
@@ -488,104 +473,28 @@
       : 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;
+  private commentThreadRedispatcher = (
+    target: EventTarget | null,
+    eventName: 'comment-thread-mouseenter' | 'comment-thread-mouseleave'
+  ) => {
+    if (!isThreadEl(target)) return;
+    const data = getDataFromCommentThreadEl(target);
+    if (data) fire(target, eventName, data);
+  };
 
-      return {side, range, rootId: threadEl.rootId};
-    }
+  private commentThreadEnterRedispatcher = (e: Event) => {
+    this.commentThreadRedispatcher(e.target, 'comment-thread-mouseenter');
+  };
 
-    // 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', () => {
-      fire(threadEl, 'comment-thread-mouseenter', {});
-    });
-    hoverEl.addEventListener('mouseleave', () => {
-      fire(threadEl, 'comment-thread-mouseleave', {});
-    });
-  }
-
-  /** Cancel any remaining diff builder rendering work. */
-  cancel() {
-    this.diffBuilder.cleanup();
-    this.renderDiffTableTask?.cancel();
-  }
+  private commentThreadLeaveRedispatcher = (e: Event) => {
+    this.commentThreadRedispatcher(e.target, 'comment-thread-mouseleave');
+  };
 
   getCursorStops(): Array<HTMLElement | AbortStop> {
     if (this.hidden && this.noAutoRender) return [];
 
     // Get rendered stops.
-    const stops: Array<HTMLElement | AbortStop> =
-      this.diffBuilder.getLineNumberRows();
+    const stops: Array<HTMLElement | AbortStop> = this.getLineNumberRows();
 
     // If we are still loading this diff, abort after the rendered stops to
     // avoid skipping over to e.g. the next file.
@@ -599,12 +508,8 @@
     return !!this.highlights.selectedRange;
   }
 
-  toggleLeftDiff() {
-    toggleClass(this, 'no-left');
-  }
-
   private blameChanged() {
-    this.diffBuilder.setBlame(this.blame);
+    this.setBlame(this.blame ?? []);
     if (this.blame) {
       this.classList.add('showBlame');
     } else {
@@ -613,107 +518,21 @@
   }
 
   // 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);
+    this.diffModel.selectLine(lineNumber, side);
   }
 
-  private dispatchSelectedLine(number: LineNumber, side: Side) {
-    fire(this, 'line-selected', {
-      number,
-      side,
-      path: this.path,
-    });
-  }
-
-  addDraftAtLine(el: Element) {
-    this.selectLine(el);
-
-    const lineNum = getLineNumber(el);
-    if (lineNum === null) {
-      fireAlert(this, 'Invalid line number');
-      return;
-    }
-
-    this.createComment(el, lineNum);
+  addDraftAtLine(lineNum: LineNumber, side: Side) {
+    this.diffModel.createCommentOnLine(lineNum, side);
   }
 
   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');
+    assertIsDefined(selectedRange, 'no range selected');
     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<CreateRangeCommentEventDetail>
-  ) {
-    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');
-    fire(this, 'create-comment', {
-      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;
+    this.diffModel.createCommentOnRange(range, side);
   }
 
   private lineOfInterestChanged() {
@@ -721,26 +540,23 @@
     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();
+    this.unhideLine(lineNum, this.lineOfInterest.side);
   }
 
   private prefsChanged() {
     if (!this.prefs) return;
-    this.diffModel.updateState({diffPrefs: this.prefs});
 
     this.blame = null;
     this.updatePreferenceStyles();
 
-    if (this.diff && !this.noRenderOnPrefsChange) {
-      this.debounceRenderDiffTable();
+    if (!Number.isInteger(this.prefs.tab_size) || this.prefs.tab_size <= 0) {
+      this.handlePreferenceError('tab size');
+    }
+    if (
+      !Number.isInteger(this.prefs.line_length) ||
+      this.prefs.line_length <= 0
+    ) {
+      this.handlePreferenceError('diff width');
     }
   }
 
@@ -754,7 +570,6 @@
 
     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',
@@ -805,324 +620,413 @@
   }
 
   private renderPrefsChanged() {
-    this.diffModel.updateState({renderPrefs: this.renderPrefs});
-    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');
-    }
+    this.classList.toggle(
+      'disable-context-control-buttons',
+      !!this.renderPrefs.disable_context_control_buttons
+    );
+    this.classList.toggle(
+      'hide-line-length-indicator',
+      !!this.renderPrefs.hide_line_length_indicator
+    );
+    this.classList.toggle('with-sign-col', !!this.renderPrefs.show_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);
+    if (this.diff && this.diffElement) {
+      this.diffSelection.init(this.diff, this.diffElement);
+      this.highlights.init(this.diffElement, this);
     }
   }
 
-  // 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 must be called once, but only after diff lines are rendered. Otherwise
+   * `processNodes()` will fail to lookup the HTML elements that it wants to
+   * manipulate.
    *
-   * 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.
+   * TODO: Validate whether the above comment is still true. We don't look up
+   * elements anymore, and processing the nodes earlier might be beneficial
+   * performance wise.
    */
-  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 `fire` 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.
-    fire(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.diff || !this.prefs) {
-      fire(this, 'render', {});
-      return;
-    }
-    if (
-      this.prefs.context === -1 &&
-      this.diffLength &&
-      this.diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
-      this.safetyBypass === null
-    ) {
-      this.showWarning = true;
-      fire(this, 'render', {});
-      return;
-    }
-
-    this.showWarning = false;
-
-    const keyLocations = this.computeKeyLocations();
-
-    this.diffModel.setState({
-      diff: this.diff,
-      path: this.path,
-      renderPrefs: this.renderPrefs,
-      diffPrefs: this.prefs,
-    });
-
-    // TODO: Setting tons of public properties like this is obviously a code
-    // smell. We are introducing 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'?
-    fire(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);
-    });
+    if (this.nodeObserver) return;
+    // Watches children being added to gr-diff. We are expecting only comment
+    // widgets to be direct children.
+    this.nodeObserver = new MutationObserver(() => this.processNodes());
     this.nodeObserver.observe(this, {childList: true});
-    // Make sure to process existing gr-comment-threads that already exist.
-    this.processNodes([...this.childNodes].filter(isThreadEl), []);
+    // Process existing comment widgets before the first observed change.
+    this.processNodes();
   }
 
-  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 processNodes() {
+    const threadEls = [...this.childNodes].filter(isThreadEl);
+    const comments = threadEls
+      .map(getDataFromCommentThreadEl)
+      .filter(isDefined)
+      .sort(compareComments);
+    this.diffModel.updateState({comments});
+    this.updateRangeLayer(comments);
+    for (const el of threadEls) {
+      el.addEventListener('mouseenter', this.commentThreadEnterRedispatcher);
+      el.addEventListener('mouseleave', this.commentThreadLeaveRedispatcher);
     }
   }
 
-  private unobserveNodes() {
-    if (this.nodeObserver) {
-      this.nodeObserver.disconnect();
-      this.nodeObserver = undefined;
+  private updateRangeLayer(threads: GrDiffCommentThread[]) {
+    const ranges: CommentRangeLayer[] = threads
+      .filter(t => !!t.range)
+      .map(t => {
+        return {range: t.range!, side: t.side, id: t.rootId};
+      });
+    if (this.highlightRange) {
+      ranges.push({side: Side.RIGHT, range: this.highlightRange, id: 'hl'});
     }
-    // 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 = [];
+    this.rangeLayer.updateRanges(ranges);
   }
 
-  private insertPortedCommentsWithoutRangeMessage(lostCell: Element) {
-    const existingMessage = lostCell.querySelector('div.lost-message');
-    if (existingMessage) return;
+  // TODO: Migrate callers to just update prefs.context.
+  toggleAllContext() {
+    const current = this.diffModel.getState().showFullContext;
+    this.diffModel.updateState({
+      showFullContext:
+        current === FullContext.YES ? FullContext.NO : FullContext.YES,
+    });
+  }
 
-    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);
+  private updateCoverageRanges(rs: CoverageRange[]) {
+    this.coverageLayerLeft.setRanges(rs.filter(r => r?.side === Side.LEFT));
+    this.coverageLayerRight.setRanges(rs.filter(r => r?.side === Side.RIGHT));
+  }
+
+  private onDiffContextExpanded = (
+    e: CustomEvent<DiffContextExpandedEventDetail>
+  ) => {
+    // Don't stop propagation. The host may listen for reporting or
+    // resizing.
+    this.diffModel.replaceGroup(e.detail.contextGroup, e.detail.groups);
+  };
+
+  private layersChanged() {
+    const layers = [...this.layersInternal, ...this.layers];
+    for (const layer of layers) {
+      layer.removeListener?.(this.layerUpdateListener);
+      layer.addListener?.(this.layerUpdateListener);
+    }
+    this.diffModel.updateState({layers});
+  }
+
+  private layersInternalInit() {
+    this.layersInternal = [
+      this.createTrailingWhitespaceLayer(),
+      this.createIntralineLayer(),
+      this.createTabIndicatorLayer(),
+      this.createSpecialCharacterIndicatorLayer(),
+      this.rangeLayer,
+      this.coverageLayerLeft,
+      this.coverageLayerRight,
+    ];
+    this.layersChanged();
+  }
+
+  getContentTdByLineEl(lineEl?: Element): Element | undefined {
+    if (!lineEl) return undefined;
+    const line = getLineNumber(lineEl);
+    if (!line) return undefined;
+    const side = getSideByLineEl(lineEl);
+    return this.getContentTdByLine(line, side);
   }
 
   /**
-   * Get the preferences object including the safety bypass context (if any).
+   * 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.
    */
-  private getBypassPrefs() {
+  unhideLine(lineNum: number, side: Side) {
     assertIsDefined(this.prefs, 'prefs');
-    if (this.safetyBypass !== null) {
-      return {...this.prefs, context: this.safetyBypass};
+    const group = this.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.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()!);
     }
-    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'
-          )
+    newGroups.push(
+      ...hideInContextControl(
+        groups,
+        lineOffset + 1 + this.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
       )
-      .map(expandFileMode);
+    );
+    this.diffModel.replaceGroup(group, newGroups);
   }
 
-  private handleFullBypass() {
-    this.safetyBypass = FULL_CONTEXT;
-    this.debounceRenderDiffTable();
+  // visible for testing
+  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}`);
   }
 
-  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();
+  // 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
+              ? getStringLength(line.text)
+              : highlight.endIndex;
+
+          GrAnnotationImpl.annotateElement(
+            contentEl,
+            highlight.startIndex,
+            endIndex - highlight.startIndex,
+            HL_CLASS
+          );
+        }
+      },
+    };
   }
 
-  toggleAllContext() {
-    if (!this.prefs) {
-      return;
-    }
-    if (this.getBypassPrefs().context < 0) {
-      this.collapseContext();
-    } else {
-      this.handleFullBypass();
+  // visible for testing
+  createTabIndicatorLayer(): DiffLayer {
+    const show = () => this.prefs?.show_tabs;
+    return {
+      annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+        if (!show()) return;
+        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.prefs?.show_whitespace_errors;
+    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 = getStringLength(line.text.substr(0, match.index));
+          const length = getStringLength(match[0]);
+          GrAnnotationImpl.annotateElement(
+            contentEl,
+            index,
+            length,
+            'gr-diff trailing-whitespace'
+          );
+        }
+      },
+    };
+  }
+
+  getContentTdByLine(
+    lineNumber: LineNumber,
+    side?: Side
+  ): HTMLTableCellElement | undefined {
+    if (!side) return undefined;
+    const row = this.findRow(side, lineNumber);
+    return row?.getContentCell(side);
+  }
+
+  getLineElByNumber(
+    lineNumber: LineNumber,
+    side?: Side
+  ): HTMLTableCellElement | undefined {
+    if (!side) return undefined;
+    const row = this.findRow(side, lineNumber);
+    return row?.getLineNumberCell(side);
+  }
+
+  private findRow(side: Side, lineNumber: LineNumber): GrDiffRow | 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() {
+    if (!this.diffElement) return [];
+    const sections = [...(this.diffElement.diffSections ?? [])];
+    return sections.map(s => s.getDiffRows()).flat();
+  }
+
+  getLineNumberRows(): HTMLTableRowElement[] {
+    const rows = this.getDiffRows();
+    return rows.map(r => r.getTableRow()).filter(isDefined);
+  }
+
+  getLineNumEls(side: Side): HTMLTableCellElement[] {
+    const rows = this.getDiffRows();
+    return rows.map(r => r.getLineNumberCell(side)).filter(isDefined);
+  }
+
+  /** This is used when layers initiate an update. */
+  private requestRowUpdates(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 computeNewlineWarning(): string | undefined {
-    const messages = [];
-    if (this.showNewlineWarningLeft) {
-      messages.push(NO_NEWLINE_LEFT);
+  private findSection(group: GrDiffGroup): GrDiffSection | undefined {
+    if (!this.diffElement) return undefined;
+    const leftClass = `left-${group.startLine(Side.LEFT)}`;
+    const rightClass = `right-${group.startLine(Side.RIGHT)}`;
+    return (
+      this.diffElement.querySelector<GrDiffSection>(
+        `gr-diff-section.${leftClass}.${rightClass}`
+      ) ?? undefined
+    );
+  }
+
+  findGroup(side: Side, line: LineNumber) {
+    return this.groups.find(group => group.containsLine(side, line));
+  }
+
+  // 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);
+  }
+
+  /**
+   * Set the blame information for the diff. For any already-rendered line,
+   * re-render its blame cell content.
+   */
+  setBlame(blame: BlameInfo[]) {
+    for (const blameInfo of blame) {
+      for (const range of blameInfo.ranges) {
+        for (let line = range.start; line <= range.end; line++) {
+          const row = this.findRow(Side.LEFT, line);
+          if (row) row.blameInfo = blameInfo;
+        }
+      }
     }
-    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 getLineNumberCellWidth(prefs: DiffPreferencesInfo) {
+  return prefs.font_size * 4;
 }
 
-function extractRemovedNodes(mutations: MutationRecord[]) {
-  return mutations.flatMap(mutation => [...mutation.removedNodes]);
+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;
+
+    GrAnnotationImpl.annotateElement(contentEl, pos, 1, `gr-diff ${className}`);
+
+    pos++;
+  }
 }
 
+customElements.define('gr-diff', GrDiff);
+
 declare global {
   interface HTMLElementTagNameMap {
     'gr-diff': GrDiff;
   }
   interface HTMLElementEventMap {
-    'comment-thread-mouseenter': CustomEvent<{}>;
-    'comment-thread-mouseleave': CustomEvent<{}>;
+    'comment-thread-mouseenter': CustomEvent<GrDiffCommentThread>;
+    'comment-thread-mouseleave': CustomEvent<GrDiffCommentThread>;
     'loading-changed': ValueChangedEvent<boolean>;
     'render-required': CustomEvent<{}>;
+    /**
+     * 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<{}>;
+    'diff-context-expanded': CustomEvent<DiffContextExpandedExternalDetail>;
+    'diff-context-expanded-internal-new': CustomEvent<DiffContextExpandedEventDetail>;
+    'content-load-needed': CustomEvent<ContentLoadNeededEventDetail>;
   }
 }
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
index f0826ad..bceafa3 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
@@ -4,35 +4,45 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../test/common-test-setup';
-import {createDiff} from '../../../test/test-data-generators';
+import {
+  createConfig,
+  createDiff,
+  createEmptyDiff,
+} from '../../../test/test-data-generators';
 import './gr-diff';
 import {getComputedStyleValue} from '../../../utils/dom-util';
 import '@polymer/paper-button/paper-button';
 import {
   DiffContent,
-  DiffInfo,
+  DiffLayer,
   DiffPreferencesInfo,
   DiffViewMode,
+  GrDiffLineType,
   IgnoreWhitespaceType,
   Side,
 } from '../../../api/diff';
 import {
-  mockPromise,
   mouseDown,
-  query,
   queryAll,
-  queryAndAssert,
-  waitEventLoop,
+  stubBaseUrl,
+  stubRestApi,
   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';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {
+  GrAnnotationImpl,
+  getStringLength,
+} from '../gr-diff-highlight/gr-annotation';
+import {GrDiffLine} from './gr-diff-line';
+import {testResolver} from '../../../test/common-test-setup';
+import {diffModelToken} from '../gr-diff-model/gr-diff-model';
+
+const DEFAULT_PREFS = createDefaultDiffPrefs();
 
 suite('gr-diff a11y test', () => {
   test('audit', async () => {
@@ -55,2943 +65,6 @@
     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 lit', async () => {
-      element.viewMode = DiffViewMode.UNIFIED;
-      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 lit', 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;
 
@@ -3021,12 +94,6 @@
     });
   });
 
-  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;
@@ -3115,400 +182,21 @@
       await element.updateComplete;
     });
 
-    test('toggleLeftDiff', () => {
-      element.toggleLeftDiff();
-      assert.isTrue(element.classList.contains('no-left'));
-      element.toggleLeftDiff();
-      assert.isFalse(element.classList.contains('no-left'));
-    });
+    test('hide_left_side', async () => {
+      await setupSampleDiff({content: []});
+      const diffModel = testResolver(diffModelToken);
 
-    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');
+      diffModel.updateState({renderPrefs: {hide_left_side: true}});
+      element.renderPrefs = {hide_left_side: true};
+      await element.updateComplete;
+      let cols = queryAll(element, 'col');
+      assert.equal(cols.length, 3);
 
-        assert.shadowDom.equal(
-          element,
-          /* HTML */ `
-            <div class="diffContainer sideBySide">
-              <gr-diff-section class="left-FILE right-FILE"> </gr-diff-section>
-              <gr-diff-row class="left-FILE right-FILE"> </gr-diff-row>
-              <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="binary-diff gr-diff"></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">
-                        <slot name="left-FILE"> </slot>
-                      </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">
-                        <slot name="right-FILE"> </slot>
-                      </div>
-                    </td>
-                  </tr>
-                </tbody>
-                <tbody class="binary-diff gr-diff">
-                  <tr class="gr-diff">
-                    <td class="gr-diff" colspan="5">
-                      <span> Difference in binary files </span>
-                    </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;
+      diffModel.updateState({renderPrefs: {hide_left_side: false}});
+      element.renderPrefs = {hide_left_side: false};
+      await element.updateComplete;
+      cols = queryAll(element, 'col');
+      assert.equal(cols.length, 5);
     });
 
     suite('getCursorStops', () => {
@@ -3529,7 +217,6 @@
           ignore_whitespace: 'IGNORE_NONE',
         };
         await element.updateComplete;
-        element.renderDiffTable();
       }
 
       test('returns [] when hidden and noAutoRender', async () => {
@@ -3545,6 +232,7 @@
       test('returns one stop per line and one for the file row', async () => {
         await setupDiff();
         element.loading = false;
+        await waitUntil(() => element.groups.length > 2);
         await element.updateComplete;
         const ROWS = 48;
         const FILE_ROW = 1;
@@ -3558,10 +246,12 @@
       test('returns an additional AbortStop when still loading', async () => {
         await setupDiff();
         element.loading = true;
+        await waitUntil(() => element.groups.length > 2);
         await element.updateComplete;
         const ROWS = 48;
         const FILE_ROW = 1;
         const LOST_ROW = 1;
+        element.loading = true;
         const actual = element.getCursorStops();
         assert.equal(actual.length, ROWS + FILE_ROW + LOST_ROW + 1);
         assert.isTrue(actual[actual.length - 1] instanceof AbortStop);
@@ -3570,26 +260,11 @@
   });
 
   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,
@@ -3656,234 +331,16 @@
         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');
+      const setBlameSpy = sinon.spy(element, 'setBlame');
       element.classList.add('showBlame');
       element.blame = null;
       await element.updateComplete;
-      assert.isTrue(setBlameSpy.calledWithExactly(null));
+      assert.isTrue(setBlameSpy.calledWithExactly([]));
       assert.isFalse(element.classList.contains('showBlame'));
     });
 
@@ -3902,111 +359,6 @@
     });
   });
 
-  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};
-      element.diff = createDiff();
-      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;
@@ -4042,38 +394,10 @@
       content,
       binary,
     };
+    await waitUntil(() => element.groups.length > 1);
     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 diffTableHasContent() {
-      assertIsDefined(element.diffTable);
-      const diffTable = element.diffTable;
-      return diffTable.innerText.includes(content[0].a?.[0] ?? '');
-    }
-    await setupSampleDiff({content});
-    await waitUntil(diffTableHasContent);
-    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
-    await waitUntil(diffTableHasContent);
-  });
-
   suite('selection test', () => {
     test('user-select set correctly on side-by-side view', async () => {
       const content = [
@@ -4089,8 +413,9 @@
         },
       ];
       await setupSampleDiff({content});
-      await waitEventLoop();
 
+      // We are selecting "Non eram nescius..." on the left side.
+      // The default is `selected-right`, so we will have to click.
       const diffLine = queryAll<HTMLElement>(element, '.contentText')[2];
       assert.equal(getComputedStyle(diffLine).userSelect, 'none');
       mouseDown(diffLine);
@@ -4110,75 +435,508 @@
           ],
         },
       ];
-      await setupSampleDiff({content});
       element.viewMode = DiffViewMode.UNIFIED;
-      await element.updateComplete;
-      const diffLine = queryAll<HTMLElement>(element, '.contentText')[2];
+      await setupSampleDiff({content});
+
+      // We are selecting "all work and no play..." on the left side.
+      // The default is `selected-right`, so we will have to click.
+      const diffLine = queryAll<HTMLElement>(element, '.contentText')[0];
       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());
+suite('former gr-diff-builder tests', () => {
+  let element: GrDiff;
+
+  const line = (text: string) => {
+    const line = new GrDiffLine(GrDiffLineType.BOTH);
+    line.text = text;
+    return line;
+  };
+
+  setup(async () => {
+    element = await fixture<GrDiff>(html`<gr-diff></gr-diff>`);
+    element.diff = createEmptyDiff();
+    await element.updateComplete;
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getProjectConfig').returns(Promise.resolve(createConfig()));
+    stubBaseUrl('/r');
+  });
+
+  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(GrAnnotationImpl, 'annotateElement');
+      layer = element.createIntralineLayer();
     });
 
-    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('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('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',
-          ],
-        },
+    test('annotate with highlights', () => {
+      const l = line(str);
+      l.highlights = [
+        {contentIndex: 0, startIndex: 6, endIndex: 12},
+        {contentIndex: 0, startIndex: 18, endIndex: 22},
       ];
-      await setupSampleDiff({content});
-      element.loading = false;
-      assert.equal(element.diffLength, 3);
-      assert.isFalse(element.showNoChangeMessage());
+      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('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('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 = 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);
     });
   });
 
-  test('getDiffLength', () => {
-    const diff = createDiff();
-    assert.equal(element.getDiffLength(diff), 52);
+  suite('tab indicators', () => {
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      element.prefs = {...DEFAULT_PREFS, show_tabs: true};
+      layer = element.createTabIndicatorLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const l = line('');
+      const el = document.createElement('div');
+      const annotateElementStub = sinon.stub(
+        GrAnnotationImpl,
+        '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(
+        GrAnnotationImpl,
+        '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(
+        GrAnnotationImpl,
+        '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.prefs = {...DEFAULT_PREFS, show_tabs: false};
+
+      const str = '\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(
+        GrAnnotationImpl,
+        '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(
+        GrAnnotationImpl,
+        '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(
+        GrAnnotationImpl,
+        '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('trailing whitespace', () => {
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      element.prefs = {
+        ...createDefaultDiffPrefs(),
+        show_whitespace_errors: true,
+      };
+      layer = element.createTrailingWhitespaceLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const l = line('');
+      const el = document.createElement('div');
+      const annotateElementStub = sinon.stub(
+        GrAnnotationImpl,
+        '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(
+        GrAnnotationImpl,
+        '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(
+        GrAnnotationImpl,
+        '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(
+        GrAnnotationImpl,
+        '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(
+        GrAnnotationImpl,
+        '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(
+        GrAnnotationImpl,
+        '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.prefs = {
+        ...createDefaultDiffPrefs(),
+        show_whitespace_errors: false,
+      };
+      const str = 'lorem upsum\t \t ';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(
+        GrAnnotationImpl,
+        'annotateElement'
+      );
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+  });
+
+  suite('context hiding and expanding', () => {
+    setup(async () => {
+      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;
+      element.prefs = {
+        ...DEFAULT_PREFS,
+        context: 1,
+      };
+      await waitUntil(() => element.groups.length > 2);
+      await element.updateComplete;
+    });
+
+    test('hides lines behind two context controls', () => {
+      const contextControls = queryAll(element, 'gr-context-controls');
+      assert.equal(contextControls.length, 2);
+
+      const diffRows = queryAll(element, '.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', async () => {
+      const contextControls = queryAll(element, 'gr-context-controls');
+      const topExpandCommonButton =
+        contextControls[0].shadowRoot?.querySelectorAll<HTMLElement>(
+          '.showContext'
+        )[0];
+      assert.isOk(topExpandCommonButton);
+      assert.include(topExpandCommonButton!.textContent, '+9 common lines');
+      let diffRows = queryAll(element, '.diff-row');
+      // 5 lines:
+      // FILE, LOST, the changed line plus one line of context in each direction
+      assert.equal(diffRows.length, 5);
+
+      topExpandCommonButton!.click();
+
+      await waitUntil(() => {
+        diffRows = queryAll(element, '.diff-row');
+        return diffRows.length === 14;
+      });
+      // 14 lines: The 5 above plus the 9 unchanged lines that were expanded
+      assert.equal(diffRows.length, 14);
+      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 () => {
+      element.unhideLine(4, Side.LEFT);
+
+      await waitUntil(() => {
+        const rows = queryAll(element, '.diff-row');
+        return rows.length === 2 + 5 + 1 + 1 + 1;
+      });
+
+      const diffRows = queryAll(element, '.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');
+    });
   });
 });
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
index d7883a0..906c130 100644
--- 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
@@ -5,7 +5,7 @@
  */
 import '../gr-range-header/gr-range-header';
 import {CommentRange} from '../../../types/common';
-import {LitElement, css, html} from 'lit';
+import {LitElement, css, html, nothing} 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';
@@ -32,16 +32,13 @@
   }
 
   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}`;
+    if (!this.range) return nothing;
+    const text = `Long comment range ${this.range.start_line} - ${this.range.end_line}`;
+    return html`
+      <div class="rangeHighlight row">
+        <gr-range-header icon="mode_comment" filled>${text}</gr-range-header>
+      </div>
+    `;
   }
 }
 
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
index 38eecfa..4d9c71a 100644
--- 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
@@ -3,25 +3,19 @@
  * 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 {GrAnnotationImpl} from '../gr-diff-highlight/gr-annotation';
+import {GrDiffLine} 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';
+import {GrDiffLineType} from '../../../api/diff';
 
-/**
- * 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 {
+  id?: string;
   side: Side;
   range: CommentRange;
-  // New drafts don't have a rootId.
-  rootId?: string;
 }
 
 /** Can be used for array functions like `some()`. */
@@ -30,7 +24,7 @@
 }
 
 function id(r: CommentRangeLayer): string {
-  if (r.rootId) return r.rootId;
+  if (r.id) return r.id;
   return `${r.side}-${r.range.start_line}-${r.range.start_character}-${r.range.end_line}-${r.range.end_character}`;
 }
 
@@ -93,7 +87,7 @@
     }
 
     for (const range of ranges) {
-      GrAnnotation.annotateElement(
+      GrAnnotationImpl.annotateElement(
         el,
         range.start,
         range.end - range.start,
@@ -192,7 +186,7 @@
   // visible for testing
   getRangesForLine(line: GrDiffLine, side: Side): CommentRangeLineLayer[] {
     const lineNum = side === Side.LEFT ? line.beforeNumber : line.afterNumber;
-    if (lineNum === 'FILE' || lineNum === 'LOST') return [];
+    if (typeof lineNum !== 'number') return [];
     const ranges: CommentRangeLineLayer[] = this.rangesMap[side][lineNum] || [];
     return ranges.map(range => {
       // Make a copy, so that the normalization below does not mess with
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
index 7feda47..7c25eeb 100644
--- 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
@@ -10,11 +10,11 @@
   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 {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffLineType, Side} from '../../../api/diff';
 import {SinonStub} from 'sinon';
 import {assert} from '@open-wc/testing';
+import {GrAnnotationImpl} from '../gr-diff-highlight/gr-annotation';
 
 const rangeA: CommentRangeLayer = {
   side: Side.LEFT,
@@ -24,7 +24,7 @@
     start_character: 6,
     start_line: 36,
   },
-  rootId: 'a',
+  id: 'a',
 };
 
 const rangeB: CommentRangeLayer = {
@@ -35,7 +35,7 @@
     start_character: 10,
     start_line: 10,
   },
-  rootId: 'b',
+  id: 'b',
 };
 
 const rangeC: CommentRangeLayer = {
@@ -56,7 +56,7 @@
     start_character: 32,
     start_line: 55,
   },
-  rootId: 'd',
+  id: 'd',
 };
 
 const rangeE: CommentRangeLayer = {
@@ -130,7 +130,7 @@
     }
 
     setup(() => {
-      annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      annotateElementStub = sinon.stub(GrAnnotationImpl, 'annotateElement');
       el = document.createElement('div');
       el.setAttribute('data-side', Side.LEFT);
       line = new GrDiffLine(GrDiffLineType.BOTH);
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
index 68aa3b4..cd10f4d 100644
--- 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
@@ -40,20 +40,22 @@
     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;
-      }
-    `,
-  ];
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          cursor: pointer;
+          font-family: var(--font-family);
+          position: absolute;
+          width: 20ch;
+        }
+        gr-tooltip[invisible] {
+          visibility: hidden;
+        }
+      `,
+    ];
+  }
 
   override render() {
     return html`
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
index da08a1f..4e166ba 100644
--- 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
@@ -3,8 +3,8 @@
  * 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 {annotateElement} from '../gr-diff-highlight/gr-annotation';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
 import {DiffFileMetaInfo, DiffInfo} from '../../../types/diff';
 import {DiffLayer, DiffLayerListener} from '../../../types/types';
 import {Side} from '../../../constants/constants';
@@ -13,6 +13,8 @@
 import {HighlightService} from '../../../services/highlight/highlight-service';
 import {Provider} from '../../../models/dependency';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {GrDiffLineType} from '../../../api/diff';
+import {assert} from '../../../utils/common-util';
 
 const LANGUAGE_MAP = new Map<string, string>([
   ['application/dart', 'dart'],
@@ -183,8 +185,8 @@
 
   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;
+    if (typeof line.beforeNumber !== 'number') return;
+    if (typeof line.afterNumber !== 'number') return;
 
     let side: Side | undefined;
     if (
@@ -203,13 +205,14 @@
 
     const isLeft = side === Side.LEFT;
     const lineNumber = isLeft ? line.beforeNumber : line.afterNumber;
+    assert(typeof lineNumber === 'number', 'lineNumber must be a number');
     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(
+      annotateElement(
         el,
         range.start,
         range.length,
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 36ebb9f..bad23cf 100644
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
@@ -3,11 +3,12 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {create, Registry, Finalizable} from '../services/registry';
+import {create, Registry} 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 {Finalizable} from '../types/types';
 
 class MockFlagsService implements FlagsService {
   isEnabled() {
diff --git a/polygerrit-ui/app/embed/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts
index 977f8a9..6de43ed 100644
--- a/polygerrit-ui/app/embed/gr-diff.ts
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -16,7 +16,7 @@
 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 {GrAnnotationImpl as GrAnnotation} from './diff/gr-diff-highlight/gr-annotation';
 import {createDiffAppContext} from './gr-diff-app-context-init';
 import {injectAppContext} from '../services/app-context';
 
@@ -29,6 +29,3 @@
   GrDiffCursor,
   TokenHighlightLayer,
 };
-
-// TODO(oler): Remove when clients have adjusted to namespaced globals above
-window.GrAnnotation = GrAnnotation;
diff --git a/polygerrit-ui/app/models/accounts-model/accounts-model.ts b/polygerrit-ui/app/models/accounts-model/accounts-model.ts
index 1c67857..6eedcbe8 100644
--- a/polygerrit-ui/app/models/accounts-model/accounts-model.ts
+++ b/polygerrit-ui/app/models/accounts-model/accounts-model.ts
@@ -10,7 +10,7 @@
 import {getUserId, isDetailedAccount} from '../../utils/account-util';
 import {hasOwnProperty} from '../../utils/common-util';
 import {define} from '../dependency';
-import {Model} from '../model';
+import {Model} from '../base/model';
 
 export interface AccountsState {
   accounts: {
@@ -42,7 +42,7 @@
   ): Promise<AccountDetailInfo | AccountInfo> {
     const current = this.getState();
     const id = getUserId(partialAccount);
-    if (hasOwnProperty(current.accounts, id)) return current.accounts[id];
+    if (hasOwnProperty(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 then
     // we at least use what is in partialAccount.
diff --git a/polygerrit-ui/app/models/accounts-model/accounts-model_test.ts b/polygerrit-ui/app/models/accounts-model/accounts-model_test.ts
index 53c90a6..e84723c 100644
--- a/polygerrit-ui/app/models/accounts-model/accounts-model_test.ts
+++ b/polygerrit-ui/app/models/accounts-model/accounts-model_test.ts
@@ -5,12 +5,23 @@
  */
 
 import '../../test/common-test-setup';
-import {EmailAddress} from '../../api/rest-api';
+import {
+  AccountDetailInfo,
+  AccountId,
+  EmailAddress,
+  Timestamp,
+} 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';
 
+const KERMIT: AccountDetailInfo = {
+  _account_id: 1 as AccountId,
+  name: 'Kermit',
+  registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+};
+
 suite('accounts-model tests', () => {
   let model: AccountsModel;
 
@@ -22,6 +33,24 @@
     model.finalize();
   });
 
+  test('basic lookup', async () => {
+    const stub = stubRestApi('getAccountDetails').returns(
+      Promise.resolve(KERMIT)
+    );
+
+    let filled = await model.fillDetails({_account_id: 1 as AccountId});
+    assert.equal(filled.name, 'Kermit');
+    assert.equal(filled, KERMIT);
+    assert.equal(stub.callCount, 1);
+
+    filled = await model.fillDetails({_account_id: 1 as AccountId});
+    assert.equal(filled.name, 'Kermit');
+    // Cache objects are cloned on lookup, so this is a different object.
+    assert.notEqual(filled, KERMIT);
+    // Did not have to call the REST API again.
+    assert.equal(stub.callCount, 1);
+  });
+
   test('invalid account makes only one request', () => {
     const response = {...new Response(), status: 404};
     const getAccountDetails = stubRestApi('getAccountDetails').callsFake(
diff --git a/polygerrit-ui/app/models/model.ts b/polygerrit-ui/app/models/base/model.ts
similarity index 95%
rename from polygerrit-ui/app/models/model.ts
rename to polygerrit-ui/app/models/base/model.ts
index 19b52fc..4574b31f 100644
--- a/polygerrit-ui/app/models/model.ts
+++ b/polygerrit-ui/app/models/base/model.ts
@@ -4,8 +4,8 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {BehaviorSubject, Observable, Subscription} from 'rxjs';
-import {Finalizable} from '../services/registry';
-import {deepEqual} from '../utils/deep-util';
+import {deepEqual} from '../../utils/deep-util';
+import {Finalizable} from '../../types/types';
 
 /**
  * A Model stores a value <T> and controls changes to that value via `subject$`
diff --git a/polygerrit-ui/app/models/model_test.ts b/polygerrit-ui/app/models/base/model_test.ts
similarity index 97%
rename from polygerrit-ui/app/models/model_test.ts
rename to polygerrit-ui/app/models/base/model_test.ts
index 3fa88e7..073c2fc 100644
--- a/polygerrit-ui/app/models/model_test.ts
+++ b/polygerrit-ui/app/models/base/model_test.ts
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {assert, waitUntil} from '@open-wc/testing';
-import '../test/common-test-setup';
+import '../../test/common-test-setup';
 import {Model} from './model';
 
 interface TestModelState {
diff --git a/polygerrit-ui/app/models/browser/browser-model.ts b/polygerrit-ui/app/models/browser/browser-model.ts
index 50b6325..86c8d62 100644
--- a/polygerrit-ui/app/models/browser/browser-model.ts
+++ b/polygerrit-ui/app/models/browser/browser-model.ts
@@ -7,7 +7,7 @@
 import {define} from '../dependency';
 import {DiffViewMode} from '../../api/diff';
 import {UserModel} from '../user/user-model';
-import {Model} from '../model';
+import {Model} from '../base/model';
 import {select} from '../../utils/observable-util';
 
 // This value is somewhat arbitrary and not based on research or calculations.
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
index b13a16f..26003e1 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
@@ -13,7 +13,7 @@
   GroupInfo,
   Hashtag,
 } from '../../api/rest-api';
-import {Model} from '../model';
+import {Model} from '../base/model';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {define} from '../dependency';
 import {select} from '../../utils/observable-util';
@@ -22,6 +22,7 @@
   ReviewerInput,
   AttentionSetInput,
   RelatedChangeAndCommitInfo,
+  ReviewResult,
 } from '../../types/common';
 import {getUserId} from '../../utils/account-util';
 import {getChangeNumber} from '../../utils/change-util';
@@ -164,7 +165,7 @@
   addReviewers(
     changedReviewers: Map<ReviewerState, (AccountInfo | GroupInfo)[]>,
     reason: string
-  ): Promise<Response>[] {
+  ): Promise<ReviewResult | undefined>[] {
     const current = this.getState();
     const changes = current.selectedChangeNums.map(
       changeNum => current.allChanges.get(changeNum)!
@@ -177,7 +178,7 @@
         this.getNewReviewersToChange(change, state, changedReviewers)
       );
       if (reviewersNewToChange.length === 0) {
-        return Promise.resolve(new Response());
+        return Promise.resolve(undefined);
       }
       const attentionSetUpdates: AttentionSetInput[] = reviewersNewToChange
         .filter(reviewerInput => reviewerInput.state === ReviewerState.REVIEWER)
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
index e2f75f7..ece126c 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
@@ -261,9 +261,7 @@
     let saveChangeReviewStub: sinon.SinonStub;
 
     setup(async () => {
-      saveChangeReviewStub = stubRestApi('saveChangeReview').resolves(
-        new Response()
-      );
+      saveChangeReviewStub = stubRestApi('saveChangeReview').resolves({});
       stubRestApi('getDetailedChangesWithActions').resolves([
         {...changes[0], actions: {abandon: {method: HttpMethod.POST}}},
         {...changes[1], status: ChangeStatus.ABANDONED},
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index 4e9f015..bba7e47 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -15,10 +15,17 @@
   RevisionPatchSetNum,
   PatchSetNumber,
   CommitId,
+  RevisionInfo,
 } from '../../types/common';
 import {ChangeStatus, DefaultBase} from '../../constants/constants';
 import {combineLatest, from, Observable, forkJoin, of} from 'rxjs';
-import {map, filter, withLatestFrom, switchMap} from 'rxjs/operators';
+import {
+  map,
+  filter,
+  withLatestFrom,
+  switchMap,
+  catchError,
+} from 'rxjs/operators';
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
@@ -26,12 +33,12 @@
   findEdit,
   sortRevisions,
 } from '../../utils/patch-set-util';
-import {isDefined, ParsedChangeInfo} from '../../types/types';
+import {isDefined, LoadingStatus, ParsedChangeInfo} from '../../types/types';
 import {fireAlert, fireTitleChange} from '../../utils/event-util';
 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 {Model} from '../base/model';
 import {UserModel} from '../user/user-model';
 import {define} from '../dependency';
 import {isOwner} from '../../utils/change-util';
@@ -49,19 +56,12 @@
 import {ReportingService} from '../../services/gr-reporting/gr-reporting';
 import {Timing} from '../../constants/reporting';
 
-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.
+   * If `change` is defined, this must be either LOADED.
    */
   loadingStatus: LoadingStatus;
   change?: ParsedChangeInfo;
@@ -183,7 +183,13 @@
 };
 
 export const changeModelToken = define<ChangeModel>('change-model');
-
+/**
+ * Change model maintains information about the current change.
+ *
+ * The "current" change is defined by ChangeViewModel. This model tracks part of
+ * the current view. As such it's a singleton global state. It's NOT meant to
+ * keep the state of an arbitrary change.
+ */
 export class ChangeModel extends Model<ChangeState> {
   private change?: ParsedChangeInfo;
 
@@ -205,8 +211,7 @@
 
   public readonly loading$ = select(
     this.changeLoadingStatus$,
-    status =>
-      status === LoadingStatus.LOADING || status === LoadingStatus.RELOADING
+    status => status === LoadingStatus.LOADING
   );
 
   public readonly reviewedFiles$ = select(
@@ -254,6 +259,11 @@
     change => change?.revisions[change.current_revision]?.uploader
   );
 
+  public readonly latestCommitter$ = select(
+    this.change$,
+    change => change?.revisions[change.current_revision]?.commit?.committer
+  );
+
   /**
    * 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
@@ -283,6 +293,12 @@
         viewModelState?.patchNum || latestPatchN
     );
 
+  /** The user can enter edit mode without an `EDIT` patchset existing yet. */
+  public readonly editMode$ = select(
+    combineLatest([this.viewModel.edit$, this.patchNum$]),
+    ([edit, patchNum]) => !!edit || patchNum === EDIT
+  );
+
   /**
    * Emits the base patchset number. This is identical to the
    * `viewModel.basePatchNum$`, but has some special logic for merges.
@@ -318,12 +334,12 @@
     );
 
   private selectRevision(
-    revisionNum$: Observable<RevisionPatchSetNum | undefined>
+    revisionNum$: Observable<RevisionPatchSetNum | BasePatchSetNum | undefined>
   ) {
     return select(
       combineLatest([this.revisions$, revisionNum$]),
       ([revisions, patchNum]) => {
-        if (!revisions || !patchNum) return undefined;
+        if (!revisions || !patchNum || patchNum === PARENT) return undefined;
         return Object.values(revisions).find(
           revision => revision._number === patchNum
         );
@@ -333,6 +349,10 @@
 
   public readonly revision$ = this.selectRevision(this.patchNum$);
 
+  public readonly baseRevision$ = this.selectRevision(
+    this.basePatchNum$
+  ) as Observable<RevisionInfo | undefined>;
+
   public readonly latestRevision$ = this.selectRevision(this.latestPatchNum$);
 
   public readonly isOwner$: Observable<boolean> = select(
@@ -363,7 +383,6 @@
       this.setDiffTitle(),
       this.setEditTitle(),
       this.reportChangeReload(),
-      this.reportSendReply(),
       this.fireShowChange(),
       this.refuseEditForOpenChange(),
       this.refuseEditForClosedChange(),
@@ -378,22 +397,9 @@
     ];
   }
 
-  private reportSendReply() {
-    return this.changeLoadingStatus$.subscribe(loadingStatus => {
-      // We are ending the timer on each change load, because ending a timer
-      // that was not started is a no-op. :-)
-      if (loadingStatus === LoadingStatus.LOADED) {
-        this.reporting.timeEnd(Timing.SEND_REPLY);
-      }
-    });
-  }
-
   private reportChangeReload() {
     return this.changeLoadingStatus$.subscribe(loadingStatus => {
-      if (
-        loadingStatus === LoadingStatus.LOADING ||
-        loadingStatus === LoadingStatus.RELOADING
-      ) {
+      if (loadingStatus === LoadingStatus.LOADING) {
         this.reporting.time(Timing.CHANGE_RELOAD);
       }
       if (
@@ -407,7 +413,6 @@
 
   private fireShowChange() {
     return combineLatest([
-      this.viewModel.childView$,
       this.change$,
       this.basePatchNum$,
       this.patchNum$,
@@ -415,15 +420,11 @@
     ])
       .pipe(
         filter(
-          ([childView, change, basePatchNum, patchNum, mergeable]) =>
-            childView === ChangeChildView.OVERVIEW &&
-            !!change &&
-            !!basePatchNum &&
-            !!patchNum &&
-            mergeable !== undefined
+          ([change, basePatchNum, patchNum, mergeable]) =>
+            !!change && !!basePatchNum && !!patchNum && mergeable !== undefined
         )
       )
-      .subscribe(([_, change, basePatchNum, patchNum, mergeable]) => {
+      .subscribe(([change, basePatchNum, patchNum, mergeable]) => {
         this.pluginLoader.jsApiService.handleShowChange({
           change,
           basePatchNum,
@@ -564,16 +565,21 @@
     return this.viewModel.changeNum$
       .pipe(
         switchMap(changeNum => {
-          if (changeNum !== undefined) this.updateStateLoading(changeNum);
-          const change = from(this.restApiService.getChangeDetail(changeNum));
-          const edit = from(this.restApiService.getChangeEdit(changeNum));
+          this.updateStateLoading(changeNum);
+          // if changeNum is undefined restApi calls return undefined.
+          const change = this.restApiService.getChangeDetail(changeNum);
+          const edit = this.restApiService.getChangeEdit(changeNum);
           return forkJoin([change, edit]);
         }),
         withLatestFrom(this.viewModel.patchNum$),
         map(([[change, edit], patchNum]) =>
           updateChangeWithEdit(change, edit, patchNum)
         ),
-        map(updateRevisionsWithCommitShas)
+        catchError(err => {
+          // Reset loading state and re-throw.
+          this.updateState({loadingStatus: LoadingStatus.NOT_LOADED});
+          throw err;
+        })
       )
       .subscribe(change => {
         // The change service is currently a singleton, so we have to be
@@ -759,25 +765,33 @@
   /**
    * 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
+   * We want to set the state to LOADING, 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;
+  private updateStateLoading(changeNum?: NumericChangeId) {
     this.updateState({
-      change: reloading ? current.change : undefined,
-      loadingStatus: reloading
-        ? LoadingStatus.RELOADING
-        : LoadingStatus.LOADING,
+      change: undefined,
+      loadingStatus: changeNum
+        ? LoadingStatus.LOADING
+        : LoadingStatus.NOT_LOADED,
     });
   }
 
   // Private but used in tests.
+  /**
+   * Update the change information in the state.
+   *
+   * Since the ChangeModel must maintain consistency with ChangeViewModel
+   * The update is only allowed, if the new change has the same number as the
+   * current change or if the current change is not set (it was reset to
+   * undefined when ChangeViewModel.changeNum updated).
+   */
   updateStateChange(change?: ParsedChangeInfo) {
+    if (this.change && change?._number !== this.change?._number) {
+      return;
+    }
+    change = updateRevisionsWithCommitShas(change);
     this.updateState({
       change,
       loadingStatus:
diff --git a/polygerrit-ui/app/models/change/change-model_test.ts b/polygerrit-ui/app/models/change/change-model_test.ts
index dc7d9c3..ebe6066 100644
--- a/polygerrit-ui/app/models/change/change-model_test.ts
+++ b/polygerrit-ui/app/models/change/change-model_test.ts
@@ -22,6 +22,7 @@
   waitUntilObserved,
 } from '../../test/test-utils';
 import {
+  BasePatchSetNum,
   CommitId,
   EDIT,
   NumericChangeId,
@@ -29,11 +30,14 @@
   PatchSetNum,
   PatchSetNumber,
 } from '../../types/common';
-import {ParsedChangeInfo} from '../../types/types';
+import {
+  EditRevisionInfo,
+  LoadingStatus,
+  ParsedChangeInfo,
+} from '../../types/types';
 import {getAppContext} from '../../services/app-context';
 import {
   ChangeState,
-  LoadingStatus,
   updateChangeWithEdit,
   updateRevisionsWithCommitShas,
 } from './change-model';
@@ -74,7 +78,9 @@
     let change: ParsedChangeInfo | undefined = createParsedChange();
     const edit = createEditInfo();
     change = updateChangeWithEdit(change, edit);
-    const editRev = change?.revisions[`${edit.commit.commit}`];
+    const editRev = change?.revisions[
+      `${edit.commit.commit}`
+    ] as EditRevisionInfo;
     assert.isDefined(editRev);
     assert.equal(editRev?._number, EDIT);
     assert.equal(editRev?.basePatchNum, edit.base_patch_set_number);
@@ -221,7 +227,7 @@
     });
   });
 
-  test('fireShowChange', async () => {
+  test('fireShowChange from overview', async () => {
     await waitForLoadingStatus(LoadingStatus.NOT_LOADED);
     const pluginLoader = testResolver(pluginLoaderToken);
     const jsApiService = pluginLoader.jsApiService;
@@ -229,6 +235,30 @@
 
     changeViewModel.updateState({
       childView: ChangeChildView.OVERVIEW,
+      basePatchNum: 2 as BasePatchSetNum,
+      patchNum: 3 as PatchSetNumber,
+    });
+    changeModel.updateState({
+      change: createParsedChange(),
+      mergeable: true,
+    });
+
+    assert.isTrue(showChangeStub.calledOnce);
+    const detail: ShowChangeDetail = showChangeStub.lastCall.firstArg;
+    assert.equal(detail.change?._number, createParsedChange()._number);
+    assert.equal(detail.patchNum, 3 as PatchSetNumber);
+    assert.equal(detail.basePatchNum, 2 as BasePatchSetNum);
+    assert.equal(detail.info.mergeable, true);
+  });
+
+  test('fireShowChange from diff', async () => {
+    await waitForLoadingStatus(LoadingStatus.NOT_LOADED);
+    const pluginLoader = testResolver(pluginLoaderToken);
+    const jsApiService = pluginLoader.jsApiService;
+    const showChangeStub = sinon.stub(jsApiService, 'handleShowChange');
+
+    changeViewModel.updateState({
+      childView: ChangeChildView.DIFF,
       patchNum: 1 as PatchSetNumber,
     });
     changeModel.updateState({
@@ -276,11 +306,11 @@
 
     // Reloading same change
     document.dispatchEvent(new CustomEvent('reload'));
-    state = await waitForLoadingStatus(LoadingStatus.RELOADING);
+    state = await waitForLoadingStatus(LoadingStatus.LOADING);
     assert.equal(stub.callCount, 3);
     assert.equal(stub.getCall(1).firstArg, undefined);
     assert.equal(stub.getCall(2).firstArg, TEST_NUMERIC_CHANGE_ID);
-    assert.deepEqual(state?.change, updateRevisionsWithCommitShas(knownChange));
+    assert.deepEqual(state?.change, undefined);
 
     promise.resolve(knownChange);
     state = await waitForLoadingStatus(LoadingStatus.LOADED);
diff --git a/polygerrit-ui/app/models/change/files-model.ts b/polygerrit-ui/app/models/change/files-model.ts
index c01b718..192520d 100644
--- a/polygerrit-ui/app/models/change/files-model.ts
+++ b/polygerrit-ui/app/models/change/files-model.ts
@@ -18,7 +18,7 @@
 import {select} from '../../utils/observable-util';
 import {FileInfoStatus, SpecialFilePath} from '../../constants/constants';
 import {specialFilePathCompare} from '../../utils/path-list-util';
-import {Model} from '../model';
+import {Model} from '../base/model';
 import {define} from '../dependency';
 import {ChangeModel} from './change-model';
 import {CommentsModel} from '../comments/comments-model';
diff --git a/polygerrit-ui/app/models/change/related-changes-model.ts b/polygerrit-ui/app/models/change/related-changes-model.ts
index 6972ec8..9c0d60f 100644
--- a/polygerrit-ui/app/models/change/related-changes-model.ts
+++ b/polygerrit-ui/app/models/change/related-changes-model.ts
@@ -10,7 +10,7 @@
 } from '../../types/common';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
 import {select} from '../../utils/observable-util';
-import {Model} from '../model';
+import {Model} from '../base/model';
 import {define} from '../dependency';
 import {ChangeModel} from './change-model';
 import {combineLatest, forkJoin, from, of} from 'rxjs';
diff --git a/polygerrit-ui/app/models/change/related-changes-model_test.ts b/polygerrit-ui/app/models/change/related-changes-model_test.ts
index 295f284..0bf7a76 100644
--- a/polygerrit-ui/app/models/change/related-changes-model_test.ts
+++ b/polygerrit-ui/app/models/change/related-changes-model_test.ts
@@ -270,7 +270,8 @@
         messages: [
           {
             ...createChangeMessage(),
-            message: 'Created a revert of this change as 123',
+            message:
+              'Created a revert of this change as If02ca1cd494579d6bb92a157bf1819e3689cd6b1',
             tag: MessageTag.TAG_REVERT as ReviewInputTag,
           },
         ],
diff --git a/polygerrit-ui/app/models/checks/checks-fakes.ts b/polygerrit-ui/app/models/checks/checks-fakes.ts
index d50ddba..1890639 100644
--- a/polygerrit-ui/app/models/checks/checks-fakes.ts
+++ b/polygerrit-ui/app/models/checks/checks-fakes.ts
@@ -26,6 +26,7 @@
   isSingleAttempt: true,
   isLatestAttempt: true,
   attemptDetails: [],
+  worstCategory: Category.ERROR,
   results: [
     {
       internalResultId: 'f0r0',
@@ -94,6 +95,7 @@
   isSingleAttempt: true,
   isLatestAttempt: true,
   attemptDetails: [],
+  worstCategory: Category.ERROR,
   results: [
     {
       internalResultId: 'f1r0',
@@ -228,6 +230,7 @@
       callback: () => Promise.resolve({message: 'fake "delete" triggered'}),
     },
   ],
+  worstCategory: Category.INFO,
   results: [
     {
       internalResultId: 'f2r0',
@@ -276,6 +279,7 @@
   isSingleAttempt: false,
   isLatestAttempt: false,
   attemptDetails: [],
+  worstCategory: Category.INFO,
   results: [
     {
       internalResultId: 'f42r0',
@@ -294,6 +298,7 @@
   isSingleAttempt: false,
   isLatestAttempt: false,
   attemptDetails: [],
+  worstCategory: Category.ERROR,
   results: [
     {
       internalResultId: 'f43r0',
@@ -320,6 +325,7 @@
   isSingleAttempt: false,
   isLatestAttempt: true,
   attemptDetails: [],
+  worstCategory: Category.INFO,
   results: [
     {
       internalResultId: 'f44r0',
@@ -380,6 +386,7 @@
     isSingleAttempt: false,
     isLatestAttempt: false,
     attemptDetails: [],
+    worstCategory: Category.ERROR,
     results:
       attempt % 2 === 0
         ? [
diff --git a/polygerrit-ui/app/models/checks/checks-model.ts b/polygerrit-ui/app/models/checks/checks-model.ts
index 25785e4..2979912 100644
--- a/polygerrit-ui/app/models/checks/checks-model.ts
+++ b/polygerrit-ui/app/models/checks/checks-model.ts
@@ -9,6 +9,7 @@
   createAttemptMap,
   LATEST_ATTEMPT,
   sortAttemptDetails,
+  worstCategory,
 } from './checks-util';
 import {assertIsDefined} from '../../utils/common-util';
 import {select} from '../../utils/observable-util';
@@ -52,7 +53,7 @@
 import {ReportingService} from '../../services/gr-reporting/gr-reporting';
 import {Execution, Interaction, Timing} from '../../constants/reporting';
 import {fireAlert, fire} from '../../utils/event-util';
-import {Model} from '../model';
+import {Model} from '../base/model';
 import {define} from '../dependency';
 import {
   ChecksPlugin,
@@ -104,6 +105,12 @@
    * List of all attempts for the same check, ordered by attempt number.
    */
   attemptDetails: AttemptDetail[];
+
+  /**
+   * The category of the worst check result in the run.
+   */
+  worstCategory?: Category;
+
   results?: CheckResult[];
 }
 
@@ -119,7 +126,18 @@
   Pick<CheckRun, 'patchset'> &
   Pick<CheckRun, 'isLatestAttempt'> &
   Pick<CheckRun, 'checkName'> &
-  Pick<CheckRun, 'labelName'> & {results?: never};
+  Pick<CheckRun, 'labelName'> &
+  Pick<CheckRun, 'status'> &
+  Pick<CheckRun, 'statusLink'> &
+  Pick<CheckRun, 'statusDescription'> &
+  Pick<CheckRun, 'startedTimestamp'> &
+  Pick<CheckRun, 'scheduledTimestamp'> &
+  Pick<CheckRun, 'finishedTimestamp'> &
+  Pick<CheckRun, 'checkLink'> &
+  Pick<CheckRun, 'checkDescription'> &
+  Pick<CheckRun, 'actions'> &
+  Pick<CheckRun, 'attemptDetails'> &
+  Pick<CheckRun, 'worstCategory'> & {results?: never};
 
 export function runResult(run: CheckRun, result: CheckResult): RunResult {
   return {
@@ -129,6 +147,17 @@
     isLatestAttempt: run.isLatestAttempt,
     checkName: run.checkName,
     labelName: run.labelName,
+    status: run.status,
+    statusLink: run.statusLink,
+    statusDescription: run.statusDescription,
+    startedTimestamp: run.startedTimestamp,
+    scheduledTimestamp: run.scheduledTimestamp,
+    finishedTimestamp: run.finishedTimestamp,
+    checkLink: run.checkLink,
+    checkDescription: run.checkDescription,
+    actions: run.actions,
+    attemptDetails: run.attemptDetails,
+    worstCategory: run.worstCategory,
     ...result,
   };
 }
@@ -381,11 +410,12 @@
   public allResults$ = select(
     combineLatest([
       this.checksSelectedPatchsetNumber$,
+      this.changeModel.latestPatchNum$,
       this.allResultsSelected$,
       this.allResultsLatest$,
     ]),
-    ([selectedPs, selected, latest]) =>
-      selectedPs ? [...selected, ...latest] : latest
+    ([selectedPs, latestPs, selected, latest]) =>
+      selectedPs && selectedPs !== latestPs ? [...selected, ...latest] : latest
   );
 
   constructor(
@@ -593,6 +623,7 @@
           isLatestAttempt: attemptInfo.latestAttempt === (run.attempt ?? 0),
           isSingleAttempt: attemptInfo.isSingleAttempt,
           attemptDetails: attemptInfo.attempts,
+          worstCategory: worstCategory(run),
           results: (run.results ?? []).map((result, i) => {
             return {
               ...result,
@@ -637,7 +668,11 @@
         }
         return result;
       });
-      return resultUpdated ? {...run, results} : run;
+      if (resultUpdated) {
+        run = {...run, results};
+        run.worstCategory = worstCategory(run);
+      }
+      return run;
     });
     if (!runUpdated) return;
     pluginState[pluginName] = {
diff --git a/polygerrit-ui/app/models/checks/checks-model_test.ts b/polygerrit-ui/app/models/checks/checks-model_test.ts
index c8fd37a..a8eda0f 100644
--- a/polygerrit-ui/app/models/checks/checks-model_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-model_test.ts
@@ -30,12 +30,16 @@
 } from '../../test/test-data-generators';
 import {waitUntil, waitUntilCalled} from '../../test/test-utils';
 import {ParsedChangeInfo} from '../../types/types';
-import {changeModelToken} from '../change/change-model';
+import {
+  changeModelToken,
+  updateRevisionsWithCommitShas,
+} 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';
+import {deepEqual} from '../../utils/deep-util';
 
 const PLUGIN_NAME = 'test-plugin';
 
@@ -106,17 +110,17 @@
     });
     await waitUntil(() => change === undefined);
 
-    const testChange = createParsedChange();
+    const testChange = updateRevisionsWithCommitShas(createParsedChange());
     testResolver(changeModelToken).updateStateChange(testChange);
-    await waitUntil(() => change === testChange);
+    await waitUntil(() => deepEqual(change, testChange));
     await waitUntilCalled(fetchSpy, 'fetch');
 
     assert.equal(
       model.latestPatchNum,
-      testChange.revisions[testChange.current_revision]
+      testChange!.revisions[testChange!.current_revision]
         ._number as PatchSetNumber
     );
-    assert.equal(model.changeNum, testChange._number);
+    assert.equal(model.changeNum, testChange!._number);
   });
 
   test('fetch throttle', async () => {
@@ -133,9 +137,9 @@
     });
     await waitUntil(() => change === undefined);
 
-    const testChange = createParsedChange();
+    const testChange = updateRevisionsWithCommitShas(createParsedChange());
     testResolver(changeModelToken).updateStateChange(testChange);
-    await waitUntil(() => change === testChange);
+    await waitUntil(() => deepEqual(change, testChange));
 
     model.reload('test-plugin');
     model.reload('test-plugin');
@@ -291,6 +295,40 @@
     assert.equal(current.runs[0].results![0].summary, 'new');
   });
 
+  test('allResults$', async () => {
+    let results: CheckResult[] | undefined = undefined;
+    model.allResults$.subscribe(allResults => (results = allResults));
+    testResolver(changeViewModelToken).updateState({
+      checksPatchset: 1 as PatchSetNumber,
+    });
+    testResolver(changeModelToken).updateStateChange(createParsedChange());
+
+    model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.SELECTED);
+    model.updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
+    assert.equal(results!.length, 0);
+
+    model.updateStateSetResults(
+      PLUGIN_NAME,
+      RUNS,
+      [],
+      [],
+      undefined,
+      ChecksPatchset.LATEST
+    );
+    assert.equal(results!.length, 1);
+
+    model.updateStateSetResults(
+      PLUGIN_NAME,
+      RUNS,
+      [],
+      [],
+      undefined,
+      ChecksPatchset.SELECTED
+    );
+
+    assert.equal(results!.length, 1);
+  });
+
   test('polls for changes', async () => {
     const clock = sinon.useFakeTimers();
     let change: ParsedChangeInfo | undefined = undefined;
@@ -305,9 +343,9 @@
     });
     await waitUntil(() => change === undefined);
     clock.tick(1);
-    const testChange = createParsedChange();
+    const testChange = updateRevisionsWithCommitShas(createParsedChange());
     testResolver(changeModelToken).updateStateChange(testChange);
-    await waitUntil(() => change === testChange);
+    await waitUntil(() => deepEqual(change, testChange));
     clock.tick(600); // need to wait for 500ms throttle
     await waitUntilCalled(fetchSpy, 'fetch');
     const pollCount = fetchSpy.callCount;
@@ -332,9 +370,9 @@
     });
     await waitUntil(() => change === undefined);
     clock.tick(1);
-    const testChange = createParsedChange();
+    const testChange = updateRevisionsWithCommitShas(createParsedChange());
     testResolver(changeModelToken).updateStateChange(testChange);
-    await waitUntil(() => change === testChange);
+    await waitUntil(() => deepEqual(change, testChange));
     clock.tick(600); // need to wait for 500ms throttle
     await waitUntilCalled(fetchSpy, 'fetch');
     clock.tick(1);
diff --git a/polygerrit-ui/app/models/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts
index 026e5e5..a567fb5 100644
--- a/polygerrit-ui/app/models/checks/checks-util.ts
+++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -164,7 +164,7 @@
   return r;
 }
 
-export function worstCategory(run: CheckRun) {
+export function worstCategory(run: CheckRunApi) {
   if (hasResultsOf(run, Category.ERROR)) return Category.ERROR;
   if (hasResultsOf(run, Category.WARNING)) return Category.WARNING;
   if (hasResultsOf(run, Category.INFO)) return Category.INFO;
@@ -288,7 +288,7 @@
   )[0];
 }
 
-export function runActions(run?: CheckRun): Action[] {
+export function runActions(run?: CheckRun | RunResult): Action[] {
   if (!run?.actions) return [];
   return run.actions.map(action => toCanonicalAction(action, run.status));
 }
@@ -297,7 +297,7 @@
   if (run.status !== RunStatus.COMPLETED) {
     return iconFor(run.status);
   } else {
-    const category = worstCategory(run);
+    const category = run.worstCategory;
     return category ? iconFor(category) : iconFor(run.status);
   }
 }
@@ -340,16 +340,16 @@
   );
 }
 
-export function hasResultsOf(run: CheckRun, category: Category) {
+export function hasResultsOf(run: CheckRunApi, category: Category) {
   return getResultsOf(run, category).length > 0;
 }
 
-export function getResultsOf(run: CheckRun, category: Category) {
+export function getResultsOf(run: CheckRunApi, 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));
+  const catComp = catLevel(b.worstCategory) - catLevel(a.worstCategory);
   if (catComp !== 0) return catComp;
   const statusComp = runLevel(b.status) - runLevel(a.status);
   return statusComp;
@@ -490,6 +490,7 @@
     isSingleAttempt: false,
     isLatestAttempt: false,
     attemptDetails: [],
+    worstCategory: worstCategory(run),
     results: (run.results ?? []).map(fromApiToInternalResult),
   };
 }
@@ -517,3 +518,12 @@
 export function secondaryLinks(result?: CheckResultApi): Link[] {
   return (result?.links ?? []).filter(link => !link.primary);
 }
+
+export function computeIsExpandable(result?: CheckResultApi) {
+  if (!result?.summary) return false;
+  const hasMessage = !!result?.message;
+  const hasMultipleLinks = (result?.links ?? []).length > 1;
+  const hasPointers = (result?.codePointers ?? []).length > 0;
+  const hasFixes = (result?.fixes ?? []).length > 0;
+  return hasMessage || hasMultipleLinks || hasPointers || hasFixes;
+}
diff --git a/polygerrit-ui/app/models/checks/checks-util_test.ts b/polygerrit-ui/app/models/checks/checks-util_test.ts
index 822435d..83b4cd6 100644
--- a/polygerrit-ui/app/models/checks/checks-util_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-util_test.ts
@@ -10,6 +10,7 @@
   ALL_ATTEMPTS,
   AttemptChoice,
   LATEST_ATTEMPT,
+  computeIsExpandable,
   rectifyFix,
   sortAttemptChoices,
   stringToAttemptChoice,
@@ -17,6 +18,12 @@
 import {Fix, Replacement} from '../../api/checks';
 import {PROVIDED_FIX_ID} from '../../utils/comment-util';
 import {CommentRange} from '../../api/rest-api';
+import {
+  createCheckFix,
+  createCheckLink,
+  createCheckResult,
+  createRange,
+} from '../../test/test-data-generators';
 
 suite('checks-util tests', () => {
   setup(() => {});
@@ -107,4 +114,62 @@
     ];
     assert.deepEqual(unsorted.sort(sortAttemptChoices), sortedExpected);
   });
+
+  suite('computeIsExpandable', () => {
+    test('no message', () => {
+      assert.isFalse(computeIsExpandable(createCheckResult()));
+    });
+
+    test('no summary', () => {
+      assert.isFalse(
+        computeIsExpandable({
+          ...createCheckResult(),
+          message: 'asdf',
+          summary: undefined as unknown as string,
+        })
+      );
+    });
+
+    test('has message', () => {
+      assert.isTrue(
+        computeIsExpandable({...createCheckResult(), message: 'asdf'})
+      );
+    });
+
+    test('has just one link', () => {
+      assert.isFalse(
+        computeIsExpandable({
+          ...createCheckResult(),
+          links: [createCheckLink()],
+        })
+      );
+    });
+
+    test('has more than one link', () => {
+      assert.isTrue(
+        computeIsExpandable({
+          ...createCheckResult(),
+          links: [createCheckLink(), createCheckLink()],
+        })
+      );
+    });
+
+    test('has code pointer', () => {
+      assert.isTrue(
+        computeIsExpandable({
+          ...createCheckResult(),
+          codePointers: [{path: 'asdf', range: createRange()}],
+        })
+      );
+    });
+
+    test('has fix', () => {
+      assert.isTrue(
+        computeIsExpandable({
+          ...createCheckResult(),
+          fixes: [createCheckFix()],
+        })
+      );
+    });
+  });
 });
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index eca8b7c..e61b88b 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -23,6 +23,7 @@
 } from '../../types/common';
 import {
   addPath,
+  convertToCommentInput,
   createNew,
   createNewPatchsetLevel,
   id,
@@ -42,7 +43,7 @@
 import {assert, assertIsDefined} from '../../utils/common-util';
 import {debounce, DelayedTask} from '../../utils/async-util';
 import {ReportingService} from '../../services/gr-reporting/gr-reporting';
-import {Model} from '../model';
+import {Model} from '../base/model';
 import {Deduping} from '../../api/reporting';
 import {extractMentionedUsers, getUserId} from '../../utils/account-util';
 import {SpecialFilePath} from '../../constants/constants';
@@ -638,7 +639,7 @@
       const result = await this.restApiService.saveDiffDraft(
         changeNum,
         draft.patch_set,
-        draft
+        convertToCommentInput(draft)
       );
       if (changeNum !== this.changeNum) return draft;
       if (!result.ok) throw new Error('request failed');
diff --git a/polygerrit-ui/app/models/config/config-model.ts b/polygerrit-ui/app/models/config/config-model.ts
index 168e0f4..dd7828b6 100644
--- a/polygerrit-ui/app/models/config/config-model.ts
+++ b/polygerrit-ui/app/models/config/config-model.ts
@@ -9,8 +9,12 @@
 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 {Model} from '../base/model';
 import {define} from '../dependency';
+import {getBaseUrl, loginUrl} from '../../utils/url-util';
+
+export const PROBE_PATH = '/Documentation/index.html';
+export const DOCS_BASE_PATH = '/Documentation';
 
 export interface ConfigState {
   repoConfig?: ConfigInfo;
@@ -34,6 +38,20 @@
     configState => configState.serverConfig
   );
 
+  public download$ = select(
+    this.serverConfig$,
+    serverConfig => serverConfig?.download
+  );
+
+  public loginUrl$ = select(this.serverConfig$, serverConfig =>
+    loginUrl(serverConfig?.auth)
+  );
+
+  public loginText$ = select(
+    this.serverConfig$,
+    serverConfig => serverConfig?.auth.login_text ?? 'Sign in'
+  );
+
   public mergeabilityComputationBehavior$ = select(
     this.serverConfig$,
     serverConfig => serverConfig?.change?.mergeability_computation_behavior
@@ -41,9 +59,7 @@
 
   public docsBaseUrl$ = select(
     this.serverConfig$.pipe(
-      switchMap(serverConfig =>
-        from(this.restApiService.getDocsBaseUrl(serverConfig))
-      )
+      switchMap(serverConfig => from(this.getDocsBaseUrl(serverConfig)))
     ),
     url => url
   );
@@ -59,7 +75,7 @@
       }),
       this.changeModel.repo$
         .pipe(
-          switchMap((repo?: RepoName) => {
+          switchMap((repo: RepoName | undefined) => {
             if (repo === undefined) return of(undefined);
             return from(this.restApiService.getProjectConfig(repo));
           })
@@ -71,6 +87,16 @@
   }
 
   // visible for testing
+  async getDocsBaseUrl(config: ServerInfo | undefined): Promise<string> {
+    if (config?.gerrit?.doc_url) return config.gerrit.doc_url;
+
+    const ok = await this.restApiService.probePath(getBaseUrl() + PROBE_PATH);
+    if (ok) return getBaseUrl() + DOCS_BASE_PATH;
+
+    return 'https://gerrit-review.googlesource.com/Documentation';
+  }
+
+  // visible for testing
   updateRepoConfig(repoConfig?: ConfigInfo) {
     this.updateState({repoConfig});
   }
diff --git a/polygerrit-ui/app/models/config/config-model_test.ts b/polygerrit-ui/app/models/config/config-model_test.ts
new file mode 100644
index 0000000..b78a933
--- /dev/null
+++ b/polygerrit-ui/app/models/config/config-model_test.ts
@@ -0,0 +1,84 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup';
+import {assert} from '@open-wc/testing';
+import {getBaseUrl} from '../../utils/url-util';
+import {
+  createGerritInfo,
+  createServerInfo,
+} from '../../test/test-data-generators';
+import {ConfigModel} from './config-model';
+import {testResolver} from '../../test/common-test-setup';
+import {getAppContext} from '../../services/app-context';
+import {changeModelToken} from '../change/change-model';
+import {ServerInfo} from '../../api/rest-api';
+
+suite('getDocsBaseUrl tests', () => {
+  let model: ConfigModel;
+
+  setup(async () => {
+    model = new ConfigModel(
+      testResolver(changeModelToken),
+      getAppContext().restApiService
+    );
+  });
+
+  test('null config', async () => {
+    const probePathMock = sinon
+      .stub(model.restApiService, 'probePath')
+      .resolves(true);
+    const docsBaseUrl = await model.getDocsBaseUrl(undefined);
+    assert.equal(
+      probePathMock.lastCall.args[0],
+      `${getBaseUrl()}/Documentation/index.html`
+    );
+    assert.equal(docsBaseUrl, `${getBaseUrl()}/Documentation`);
+  });
+
+  test('no doc config', async () => {
+    const probePathMock = sinon
+      .stub(model.restApiService, 'probePath')
+      .resolves(true);
+    const config: ServerInfo = {
+      ...createServerInfo(),
+      gerrit: createGerritInfo(),
+    };
+    const docsBaseUrl = await model.getDocsBaseUrl(config);
+    assert.equal(
+      probePathMock.lastCall.args[0],
+      `${getBaseUrl()}/Documentation/index.html`
+    );
+    assert.equal(docsBaseUrl, `${getBaseUrl()}/Documentation`);
+  });
+
+  test('has doc config', async () => {
+    const probePathMock = sinon
+      .stub(model.restApiService, 'probePath')
+      .resolves(true);
+    const config: ServerInfo = {
+      ...createServerInfo(),
+      gerrit: {...createGerritInfo(), doc_url: 'foobar'},
+    };
+    const docsBaseUrl = await model.getDocsBaseUrl(config);
+    assert.isFalse(probePathMock.called);
+    assert.equal(docsBaseUrl, 'foobar');
+  });
+
+  test('no probe', async () => {
+    const probePathMock = sinon
+      .stub(model.restApiService, 'probePath')
+      .resolves(false);
+    const docsBaseUrl = await model.getDocsBaseUrl(undefined);
+    assert.equal(
+      probePathMock.lastCall.args[0],
+      `${getBaseUrl()}/Documentation/index.html`
+    );
+    assert.equal(
+      docsBaseUrl,
+      'https://gerrit-review.googlesource.com/Documentation'
+    );
+  });
+});
diff --git a/polygerrit-ui/app/models/plugins/plugins-model.ts b/polygerrit-ui/app/models/plugins/plugins-model.ts
index 83235b17..33ec35a 100644
--- a/polygerrit-ui/app/models/plugins/plugins-model.ts
+++ b/polygerrit-ui/app/models/plugins/plugins-model.ts
@@ -10,9 +10,10 @@
   ChecksApiConfig,
   ChecksProvider,
 } from '../../api/checks';
-import {Model} from '../model';
+import {Model} from '../base/model';
 import {select} from '../../utils/observable-util';
-import {CoverageProvider} from '../../api/annotation';
+import {CoverageProvider, TokenHoverListener} from '../../api/annotation';
+import {SuggestionsProvider} from '../../api/suggestions';
 
 export interface CoveragePlugin {
   pluginName: string;
@@ -25,6 +26,16 @@
   config: ChecksApiConfig;
 }
 
+export interface SuggestionPlugin {
+  pluginName: string;
+  provider: SuggestionsProvider;
+}
+
+export interface TokenHoverListenerPlugin {
+  pluginName: string;
+  listener: TokenHoverListener;
+}
+
 export interface ChecksUpdate {
   pluginName: string;
   run: CheckRun;
@@ -41,6 +52,17 @@
    * List of plugins that have called checks().register().
    */
   checksPlugins: ChecksPlugin[];
+
+  /**
+   * List of plugins that have called suggestions().register().
+   */
+  suggestionsPlugins: SuggestionPlugin[];
+
+  /**
+   * List of plugins that have called
+   * annotationApi().addTokenHoverListener().
+   */
+  tokenHighlightPlugins: TokenHoverListenerPlugin[];
 }
 
 export class PluginsModel extends Model<PluginsState> {
@@ -66,6 +88,8 @@
     super({
       coveragePlugins: [],
       checksPlugins: [],
+      suggestionsPlugins: [],
+      tokenHighlightPlugins: [],
     });
   }
 
@@ -101,6 +125,38 @@
     this.setState(nextState);
   }
 
+  suggestionsRegister(plugin: SuggestionPlugin) {
+    const nextState = {...this.getState()};
+    nextState.suggestionsPlugins = [...nextState.suggestionsPlugins];
+    const alreadyRegistered = nextState.suggestionsPlugins.some(
+      p => p.pluginName === plugin.pluginName
+    );
+    if (alreadyRegistered) {
+      console.warn(
+        `${plugin.pluginName} tried to register twice as a suggestion provider. Ignored.`
+      );
+      return;
+    }
+    nextState.suggestionsPlugins.push(plugin);
+    this.setState(nextState);
+  }
+
+  tokenHoverListenerRegister(plugin: TokenHoverListenerPlugin) {
+    const nextState = {...this.getState()};
+    nextState.tokenHighlightPlugins = [...nextState.tokenHighlightPlugins];
+    const alreadyRegistered = nextState.tokenHighlightPlugins.some(
+      p => p.pluginName === plugin.pluginName
+    );
+    if (alreadyRegistered) {
+      console.warn(
+        `${plugin.pluginName} tried to register twice as a hover callback. Ignored.`
+      );
+      return;
+    }
+    nextState.tokenHighlightPlugins.push(plugin);
+    this.setState(nextState);
+  }
+
   checksUpdate(update: ChecksUpdate) {
     const plugins = this.getState().checksPlugins;
     const plugin = plugins.find(p => p.pluginName === update.pluginName);
diff --git a/polygerrit-ui/app/models/user/user-model.ts b/polygerrit-ui/app/models/user/user-model.ts
index 97f90fa..55d387b 100644
--- a/polygerrit-ui/app/models/user/user-model.ts
+++ b/polygerrit-ui/app/models/user/user-model.ts
@@ -14,20 +14,30 @@
   AccountDetailInfo,
   EditPreferencesInfo,
   PreferencesInfo,
+  TopMenuItemInfo,
 } from '../../types/common';
 import {
   createDefaultPreferences,
   createDefaultDiffPrefs,
   createDefaultEditPrefs,
   AppTheme,
+  ColumnNames,
 } 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 {Model} from '../base/model';
 import {isDefined} from '../../types/types';
 
+export function changeTablePrefs(prefs: Partial<PreferencesInfo>) {
+  const cols = prefs.change_table ?? [];
+  if (cols.length === 0) return Object.values(ColumnNames);
+  return cols
+    .map(column => (column === 'Project' ? ColumnNames.REPO : column))
+    .map(column => (column === ' Status ' ? ColumnNames.STATUS : column));
+}
+
 export interface UserState {
   /**
    * Keeps being defined even when credentials have expired.
@@ -123,6 +133,11 @@
     preference => preference.theme
   );
 
+  readonly myMenuItems$: Observable<TopMenuItemInfo[]> = select(
+    this.preferences$,
+    preference => preference?.my ?? []
+  );
+
   readonly preferenceChangesPerPage$: Observable<number> = select(
     this.preferences$,
     preference => preference.changes_per_page
@@ -182,6 +197,10 @@
   }
 
   updatePreferences(prefs: Partial<PreferencesInfo>) {
+    this.setPreferences({
+      ...(this.getState().preferences ?? createDefaultPreferences()),
+      ...prefs,
+    });
     return this.restApiService
       .savePreferences(prefs)
       .then((newPrefs: PreferencesInfo | undefined) => {
diff --git a/polygerrit-ui/app/models/views/admin.ts b/polygerrit-ui/app/models/views/admin.ts
index 017470e..02b5796 100644
--- a/polygerrit-ui/app/models/views/admin.ts
+++ b/polygerrit-ui/app/models/views/admin.ts
@@ -6,7 +6,7 @@
 import {GerritView} from '../../services/router/router-model';
 import {getBaseUrl} from '../../utils/url-util';
 import {define} from '../dependency';
-import {Model} from '../model';
+import {Model} from '../base/model';
 import {Route, ViewState} from './base';
 import {
   RepoName,
diff --git a/polygerrit-ui/app/models/views/agreement.ts b/polygerrit-ui/app/models/views/agreement.ts
index 839699c..ad587eb 100644
--- a/polygerrit-ui/app/models/views/agreement.ts
+++ b/polygerrit-ui/app/models/views/agreement.ts
@@ -5,7 +5,7 @@
  */
 import {GerritView} from '../../services/router/router-model';
 import {define} from '../dependency';
-import {Model} from '../model';
+import {Model} from '../base/model';
 import {ViewState} from './base';
 
 export interface AgreementViewState extends ViewState {
diff --git a/polygerrit-ui/app/models/views/change.ts b/polygerrit-ui/app/models/views/change.ts
index f003e3f..06d981a 100644
--- a/polygerrit-ui/app/models/views/change.ts
+++ b/polygerrit-ui/app/models/views/change.ts
@@ -24,7 +24,7 @@
 } from '../../utils/url-util';
 import {AttemptChoice} from '../checks/checks-util';
 import {define} from '../dependency';
-import {Model} from '../model';
+import {Model} from '../base/model';
 import {ViewState} from './base';
 
 export enum ChangeChildView {
@@ -204,25 +204,33 @@
     childView: ChangeChildView.DIFF,
   });
 
-  const path = `/${encodeURL(state.diffView?.path ?? '')}`;
-
-  let suffix = '';
+  let path = `/${encodeURL(state.diffView?.path ?? '')}`;
   // 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}/`;
+    path += `comment/${state.commentId}/`;
   }
 
+  let queryParams = '';
+  const params = [];
+  if (state.checksPatchset && state.checksPatchset > 0) {
+    params.push(`checksPatchset=${state.checksPatchset}`);
+  }
+  if (params.length > 0) {
+    queryParams = '?' + params.join('&');
+  }
+
+  let hash = '';
   if (state.diffView?.lineNum) {
-    suffix += '#';
+    hash += '#';
     if (state.diffView?.leftSide) {
-      suffix += 'b';
+      hash += 'b';
     }
-    suffix += state.diffView.lineNum;
+    hash += state.diffView.lineNum;
   }
 
-  return `${createChangeUrlCommon(state)}${path}${suffix}`;
+  return `${createChangeUrlCommon(state)}${path}${queryParams}${hash}`;
 }
 
 export function createEditUrl(
diff --git a/polygerrit-ui/app/models/views/change_test.ts b/polygerrit-ui/app/models/views/change_test.ts
index 837e362..8e671d1 100644
--- a/polygerrit-ui/app/models/views/change_test.ts
+++ b/polygerrit-ui/app/models/views/change_test.ts
@@ -6,6 +6,7 @@
 import {assert} from '@open-wc/testing';
 import {
   BasePatchSetNum,
+  PatchSetNumber,
   RepoName,
   RevisionPatchSetNum,
 } from '../../api/rest-api';
@@ -77,59 +78,97 @@
     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'
-    );
+  suite('createDiffUrl', () => {
+    let params: ChangeViewState;
+    setup(() => {
+      params = {
+        ...createDiffViewState(),
+        patchNum: 12 as RevisionPatchSetNum,
+        diffView: {path: 'x+y/path.cpp'},
+      };
+    });
 
-    window.CANONICAL_PATH = '/base';
-    assert.equal(createDiffUrl(params).substring(0, 5), '/base');
-    window.CANONICAL_PATH = undefined;
+    test('CANONICAL_PATH', () => {
+      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');
+    test('basic', () => {
+      assert.equal(
+        createDiffUrl(params),
+        '/c/test-project/+/42/12/x%252By/path.cpp'
+      );
+    });
 
-    params.basePatchNum = 6 as BasePatchSetNum;
-    assert.equal(createDiffUrl(params), '/c/test/+/42/6..12/x%252By/path.cpp');
+    test('repo', () => {
+      params.repo = 'test' as RepoName;
+      assert.equal(createDiffUrl(params), '/c/test/+/42/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'
-    );
+    test('checks patchset', () => {
+      params.checksPatchset = 4 as PatchSetNumber;
+      assert.equal(
+        createDiffUrl(params),
+        '/c/test-project/+/42/12/x%252By/path.cpp?checksPatchset=4'
+      );
+    });
 
-    params.diffView = {
-      path: 'file.cpp',
-      lineNum: 123,
-    };
-    assert.equal(createDiffUrl(params), '/c/test/+/42/2/file.cpp#123');
+    test('base patchset', () => {
+      params.basePatchNum = 6 as BasePatchSetNum;
+      assert.equal(
+        createDiffUrl(params),
+        '/c/test-project/+/42/6..12/x%252By/path.cpp'
+      );
+    });
 
-    params.diffView = {
-      path: 'file.cpp',
-      lineNum: 123,
-      leftSide: true,
-    };
-    assert.equal(createDiffUrl(params), '/c/test/+/42/2/file.cpp#b123');
-  });
+    test('percent', () => {
+      params.diffView = {
+        path: 'foo bar/my+file.txt%',
+      };
+      params.patchNum = 2 as RevisionPatchSetNum;
+      delete params.basePatchNum;
+      assert.equal(
+        createDiffUrl(params),
+        '/c/test-project/+/42/2/foo+bar/my%252Bfile.txt%2525'
+      );
+    });
 
-  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('line right', () => {
+      params.diffView = {
+        path: 'file.cpp',
+        lineNum: 123,
+      };
+      assert.equal(
+        createDiffUrl(params),
+        '/c/test-project/+/42/12/file.cpp#123'
+      );
+    });
+
+    test('line left', () => {
+      params.diffView = {
+        path: 'file.cpp',
+        lineNum: 123,
+        leftSide: true,
+      };
+      assert.equal(
+        createDiffUrl(params),
+        '/c/test-project/+/42/12/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', () => {
diff --git a/polygerrit-ui/app/models/views/dashboard.ts b/polygerrit-ui/app/models/views/dashboard.ts
index d2e7995..f5fc8b4 100644
--- a/polygerrit-ui/app/models/views/dashboard.ts
+++ b/polygerrit-ui/app/models/views/dashboard.ts
@@ -9,7 +9,7 @@
 import {DashboardSection} from '../../utils/dashboard-util';
 import {encodeURL, getBaseUrl} from '../../utils/url-util';
 import {define} from '../dependency';
-import {Model} from '../model';
+import {Model} from '../base/model';
 import {Route, ViewState} from './base';
 
 export const PROJECT_DASHBOARD_ROUTE: Route<DashboardViewState> = {
@@ -19,6 +19,7 @@
     const dashboard = (ctx.params[1] ?? '') as DashboardId;
     const state: DashboardViewState = {
       view: GerritView.DASHBOARD,
+      type: DashboardType.REPO,
       project,
       dashboard,
     };
@@ -26,8 +27,15 @@
   },
 };
 
+export enum DashboardType {
+  USER,
+  REPO,
+  CUSTOM,
+}
+
 export interface DashboardViewState extends ViewState {
   view: GerritView.DASHBOARD;
+  type: DashboardType;
   project?: RepoName;
   dashboard?: DashboardId;
   user?: string;
diff --git a/polygerrit-ui/app/models/views/dashboard_test.ts b/polygerrit-ui/app/models/views/dashboard_test.ts
index 9509977..47f4868a 100644
--- a/polygerrit-ui/app/models/views/dashboard_test.ts
+++ b/polygerrit-ui/app/models/views/dashboard_test.ts
@@ -11,6 +11,7 @@
 import {DashboardId} from '../../types/common';
 import {
   createDashboardUrl,
+  DashboardType,
   DashboardViewState,
   PROJECT_DASHBOARD_ROUTE,
 } from './dashboard';
@@ -23,6 +24,7 @@
 
       const state: DashboardViewState = {
         view: GerritView.DASHBOARD,
+        type: DashboardType.REPO,
         project: 'asdf' as RepoName,
         dashboard: 'qwer' as DashboardId,
       };
@@ -37,21 +39,31 @@
 
   suite('createDashboardUrl()', () => {
     test('self dashboard', () => {
-      assert.equal(createDashboardUrl({}), '/dashboard/self');
+      assert.equal(
+        createDashboardUrl({type: DashboardType.USER}),
+        '/dashboard/self'
+      );
     });
 
     test('baseUrl', () => {
       window.CANONICAL_PATH = '/base';
-      assert.equal(createDashboardUrl({}).substring(0, 5), '/base');
+      assert.equal(
+        createDashboardUrl({type: DashboardType.USER}).substring(0, 5),
+        '/base'
+      );
       window.CANONICAL_PATH = undefined;
     });
 
     test('user dashboard', () => {
-      assert.equal(createDashboardUrl({user: 'user'}), '/dashboard/user');
+      assert.equal(
+        createDashboardUrl({type: DashboardType.USER, user: 'user'}),
+        '/dashboard/user'
+      );
     });
 
     test('custom self dashboard, no title', () => {
       const state = {
+        type: DashboardType.CUSTOM,
         sections: [
           {name: 'section 1', query: 'query 1'},
           {name: 'section 2', query: 'query 2'},
@@ -65,6 +77,7 @@
 
     test('custom repo dashboard', () => {
       const state = {
+        type: DashboardType.CUSTOM,
         sections: [
           {name: 'section 1', query: 'query 1 ${project}'},
           {name: 'section 2', query: 'query 2 ${repo}'},
@@ -80,6 +93,7 @@
 
     test('custom user dashboard, with title', () => {
       const state = {
+        type: DashboardType.CUSTOM,
         user: 'user',
         sections: [{name: 'name', query: 'query'}],
         title: 'custom dashboard',
@@ -92,6 +106,7 @@
 
     test('repo dashboard', () => {
       const state = {
+        type: DashboardType.REPO,
         project: 'gerrit/repo' as RepoName,
         dashboard: 'default:main' as DashboardId,
       };
@@ -103,6 +118,7 @@
 
     test('project dashboard (legacy)', () => {
       const state = {
+        type: DashboardType.REPO,
         project: 'gerrit/project' as RepoName,
         dashboard: 'default:main' as DashboardId,
       };
diff --git a/polygerrit-ui/app/models/views/documentation.ts b/polygerrit-ui/app/models/views/documentation.ts
index abb0f03..118fdf9 100644
--- a/polygerrit-ui/app/models/views/documentation.ts
+++ b/polygerrit-ui/app/models/views/documentation.ts
@@ -6,7 +6,7 @@
 import {GerritView} from '../../services/router/router-model';
 import {getBaseUrl} from '../../utils/url-util';
 import {define} from '../dependency';
-import {Model} from '../model';
+import {Model} from '../base/model';
 import {ViewState} from './base';
 
 export interface DocumentationViewState extends ViewState {
@@ -14,6 +14,10 @@
   filter: string;
 }
 
+/**
+ * This is just for documentation *searches*, not for static documentation
+ * URLs. See `getDocUrl()` in url-util.ts.
+ */
 export function createDocumentationUrl() {
   return `${getBaseUrl()}/Documentation`;
 }
diff --git a/polygerrit-ui/app/models/views/group.ts b/polygerrit-ui/app/models/views/group.ts
index f4a7c78..2297bc4 100644
--- a/polygerrit-ui/app/models/views/group.ts
+++ b/polygerrit-ui/app/models/views/group.ts
@@ -7,7 +7,7 @@
 import {GroupId} from '../../types/common';
 import {encodeURL, getBaseUrl} from '../../utils/url-util';
 import {define} from '../dependency';
-import {Model} from '../model';
+import {Model} from '../base/model';
 import {ViewState} from './base';
 
 export enum GroupDetailView {
diff --git a/polygerrit-ui/app/models/views/plugin.ts b/polygerrit-ui/app/models/views/plugin.ts
index ac7e925..e3fc271 100644
--- a/polygerrit-ui/app/models/views/plugin.ts
+++ b/polygerrit-ui/app/models/views/plugin.ts
@@ -4,10 +4,24 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {GerritView} from '../../services/router/router-model';
+import {select} from '../../utils/observable-util';
 import {define} from '../dependency';
-import {Model} from '../model';
+import {Model} from '../base/model';
 import {ViewState} from './base';
 
+/**
+ * This is simple hacky way for allowing certain plugin screens to hide the
+ * header and the footer of the Gerrit page.
+ */
+export const ALLOW_LISTED_FULL_SCREEN_PLUGINS = [
+  'git_source_editor/screen/edit',
+];
+
+export function screenName(plugin?: string, screen?: string) {
+  if (!plugin || !screen) return '';
+  return `${plugin}-screen-${screen}`;
+}
+
 export interface PluginViewState extends ViewState {
   view: GerritView.PLUGIN_SCREEN;
   plugin?: string;
@@ -20,6 +34,10 @@
   define<PluginViewModel>('plugin-view-model');
 
 export class PluginViewModel extends Model<PluginViewState> {
+  public readonly screenName$ = select(this.state$, state =>
+    screenName(state.plugin, state.screen)
+  );
+
   constructor() {
     super(DEFAULT_STATE);
   }
diff --git a/polygerrit-ui/app/models/views/repo.ts b/polygerrit-ui/app/models/views/repo.ts
index 66bf5bf..1629449 100644
--- a/polygerrit-ui/app/models/views/repo.ts
+++ b/polygerrit-ui/app/models/views/repo.ts
@@ -7,7 +7,7 @@
 import {BranchName, RepoName} from '../../types/common';
 import {encodeURL, getBaseUrl} from '../../utils/url-util';
 import {define} from '../dependency';
-import {Model} from '../model';
+import {Model} from '../base/model';
 import {ViewState} from './base';
 
 export enum RepoDetailView {
diff --git a/polygerrit-ui/app/models/views/search.ts b/polygerrit-ui/app/models/views/search.ts
index 2edc540..bc17a58 100644
--- a/polygerrit-ui/app/models/views/search.ts
+++ b/polygerrit-ui/app/models/views/search.ts
@@ -21,7 +21,7 @@
 import {escapeAndWrapSearchOperatorValue} from '../../utils/string-util';
 import {encodeURL, getBaseUrl} from '../../utils/url-util';
 import {define, Provider} from '../dependency';
-import {Model} from '../model';
+import {Model} from '../base/model';
 import {UserModel} from '../user/user-model';
 import {ViewState} from './base';
 import {createChangeUrl} from './change';
diff --git a/polygerrit-ui/app/models/views/settings.ts b/polygerrit-ui/app/models/views/settings.ts
index c1a8c08..0a6285f 100644
--- a/polygerrit-ui/app/models/views/settings.ts
+++ b/polygerrit-ui/app/models/views/settings.ts
@@ -7,7 +7,7 @@
 import {select} from '../../utils/observable-util';
 import {getBaseUrl} from '../../utils/url-util';
 import {define} from '../dependency';
-import {Model} from '../model';
+import {Model} from '../base/model';
 import {ViewState} from './base';
 
 export interface SettingsViewState extends ViewState {
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index 126fd1f..9fa2e89 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -101,6 +101,10 @@
     license: SharedLicenses.Lit,
   },
   {
+    name: '@lit-labs/ssr-dom-shim',
+    license: SharedLicenses.Lit,
+  },
+  {
     name: '@polymer/decorators',
     license: SharedLicenses.Polymer2017,
   },
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index edf0206..a5dc421 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -5,7 +5,7 @@
   "dependencies": {
     "@polymer/decorators": "^3.0.0",
     "@polymer/font-roboto-local": "^3.0.2",
-    "@polymer/iron-a11y-announcer": "^3.1.0",
+    "@polymer/iron-a11y-announcer": "^3.2.0",
     "@polymer/iron-autogrow-textarea": "^3.0.3",
     "@polymer/iron-dropdown": "^3.0.1",
     "@polymer/iron-fit-behavior": "^3.1.0",
@@ -29,23 +29,30 @@
     "@polymer/paper-tabs": "^3.1.0",
     "@polymer/paper-toggle-button": "^3.0.1",
     "@polymer/paper-tooltip": "^3.0.1",
-    "@polymer/polymer": "^3.4.1",
-    "@types/resemblejs": "^3.2.0",
-    "@types/resize-observer-browser": "^0.1.5",
-    "@webcomponents/shadycss": "^1.10.2",
+    "@polymer/polymer": "^3.5.1",
+    "@types/resemblejs": "^4.1.0",
+    "@types/resize-observer-browser": "^0.1.7",
+    "@webcomponents/shadycss": "^1.11.2",
     "@webcomponents/webcomponentsjs": "^1.3.3",
-    "highlight.js": "^11.5.0",
+    "highlight.js": "^11.8.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.2.3",
-    "polymer-bridges": "file:../../polymer-bridges/",
+    "immer": "^9.0.21",
+    "lit": "^3.0.0",
+    "polymer-bridges": "file:../../polymer-bridges",
     "polymer-resin": "^2.0.1",
-    "resemblejs": "^4.0.0",
+    "resemblejs": "^5.0.0",
     "rxjs": "^6.6.7",
-    "safevalues": "^0.3.1",
-    "web-vitals": "^3.0.0"
+    "safevalues": "0.3.1",
+    "web-vitals": "^3.4.0"
+  },
+  "dependencies // comments": {
+    "safevalues": [
+      "There is a an issue with release 0.3.2, which exposes both an ESM and a CommonJS module:",
+      "https://github.com/google/safevalues/commit/16aa2567dc303759841b097b1901d1d6ff4e083e",
+      "That causes tests to fail claiming that 'sanitizeHtml' is not exported from safevalues."
+    ]
   },
   "license": "Apache-2.0",
   "private": true
-}
+}
\ 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 8589ae3..40517ac 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {AppContext} from './app-context';
-import {create, Finalizable, Registry} from './registry';
+import {create, Registry} from './registry';
 import {DependencyToken} from '../models/dependency';
 import {FlagsServiceImplementation} from './flags/flags_impl';
 import {GrReporting} from './gr-reporting/gr-reporting_impl';
@@ -72,6 +72,7 @@
   RelatedChangesModel,
   relatedChangesModelToken,
 } from '../models/change/related-changes-model';
+import {Finalizable} from '../types/types';
 
 /**
  * The AppContext lazy initializator for all services
@@ -87,7 +88,8 @@
     authService: (_ctx: Partial<AppContext>) => new Auth(),
     restApiService: (ctx: Partial<AppContext>) => {
       assertIsDefined(ctx.authService, 'authService');
-      return new GrRestApiServiceImpl(ctx.authService);
+      assertIsDefined(ctx.flagsService, 'flagsService');
+      return new GrRestApiServiceImpl(ctx.authService, ctx.flagsService);
     },
   };
   return create<AppContext>(appRegistry);
diff --git a/polygerrit-ui/app/services/app-context-init_test.ts b/polygerrit-ui/app/services/app-context-init_test.ts
index 7834e53..3572189 100644
--- a/polygerrit-ui/app/services/app-context-init_test.ts
+++ b/polygerrit-ui/app/services/app-context-init_test.ts
@@ -5,9 +5,9 @@
  */
 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';
+import {Finalizable} from '../types/types';
 
 suite('app context initializer tests', () => {
   let appContext: AppContext & Finalizable;
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index aa2c032..eeddc8e 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -3,11 +3,11 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {Finalizable} from './registry';
 import {FlagsService} from './flags/flags';
 import {ReportingService} from './gr-reporting/gr-reporting';
 import {AuthService} from './gr-auth/gr-auth';
 import {RestApiService} from './gr-rest-api/gr-rest-api';
+import {Finalizable} from '../types/types';
 
 export interface AppContext {
   flagsService: FlagsService;
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 7488e79..b7d36f4 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -3,7 +3,8 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {Finalizable} from '../registry';
+
+import {Finalizable} from '../../types/types';
 
 export interface FlagsService extends Finalizable {
   isEnabled(experimentId: string): boolean;
@@ -18,5 +19,6 @@
   CHECKS_DEVELOPER = 'UiFeature__checks_developer',
   PUSH_NOTIFICATIONS_DEVELOPER = 'UiFeature__push_notifications_developer',
   PUSH_NOTIFICATIONS = 'UiFeature__push_notifications',
-  SUGGEST_EDIT = 'UiFeature__suggest_edit',
+  ML_SUGGESTED_EDIT = 'UiFeature__ml_suggested_edit',
+  REVISION_PARENTS_DATA = 'UiFeature__revision_parents_data',
 }
diff --git a/polygerrit-ui/app/services/flags/flags_impl.ts b/polygerrit-ui/app/services/flags/flags_impl.ts
index 4ef55a2..ca9aa60 100644
--- a/polygerrit-ui/app/services/flags/flags_impl.ts
+++ b/polygerrit-ui/app/services/flags/flags_impl.ts
@@ -3,8 +3,8 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+import {Finalizable} from '../../types/types';
 import {FlagsService} from './flags';
-import {Finalizable} from '../registry';
 
 declare global {
   interface Window {
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth.ts b/polygerrit-ui/app/services/gr-auth/gr-auth.ts
index 945d6f9..a6b4fdd 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth.ts
@@ -4,8 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {define} from '../../models/dependency';
-import {AuthRequestInit} from '../../types/types';
-import {Finalizable} from '../registry';
+import {AuthRequestInit, Finalizable} from '../../types/types';
 export enum AuthType {
   XSRF_TOKEN = 'xsrf_token',
   ACCESS_TOKEN = 'access_token',
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 2312fc9..fb53d5e 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
@@ -3,10 +3,9 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {AuthRequestInit} from '../../types/types';
+import {AuthRequestInit, Finalizable} from '../../types/types';
 import {fire} from '../../utils/event-util';
 import {getBaseUrl} from '../../utils/url-util';
-import {Finalizable} from '../registry';
 import {
   AuthService,
   AuthStatus,
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 49259b4..6df2c67 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -3,7 +3,6 @@
  * Copyright 2020 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {Finalizable} from '../registry';
 import {NumericChangeId} from '../../types/common';
 import {EventDetails, ReportingOptions} from '../../api/reporting';
 import {PluginApi} from '../../api/plugin';
@@ -13,6 +12,7 @@
   LifeCycle,
   Timing,
 } from '../../constants/reporting';
+import {Finalizable} from '../../types/types';
 
 export type EventValue = string | number | {error?: Error};
 
@@ -34,6 +34,7 @@
 
   appStarted(): void;
   onVisibilityChange(): void;
+  onFocusChange(): void;
   beforeLocationChanged(): void;
   locationChanged(page: string): void;
   dashboardDisplayed(): 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 cc54c4a..b6bb560 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -9,7 +9,6 @@
 import {NumericChangeId} from '../../types/common';
 import {Deduping, EventDetails, ReportingOptions} from '../../api/reporting';
 import {PluginApi} from '../../api/plugin';
-import {Finalizable} from '../registry';
 import {
   Execution,
   Interaction,
@@ -18,6 +17,7 @@
 } from '../../constants/reporting';
 import {onCLS, onFID, onLCP, Metric, onINP} from 'web-vitals';
 import {getEventPath, isElementTarget} from '../../utils/dom-util';
+import {Finalizable} from '../../types/types';
 
 // Latency reporting constants.
 
@@ -88,6 +88,9 @@
   [Timing.WEB_COMPONENTS_READY]: 0,
 };
 
+// List of timers that should NOT be reset before a location change.
+const LOCATION_CHANGE_OK_TIMERS: (string | Timing)[] = [Timing.SEND_REPLY];
+
 const SLOW_RPC_THRESHOLD = 500;
 
 export function initErrorReporter(reportingService: ReportingService) {
@@ -185,6 +188,12 @@
   document.addEventListener('visibilitychange', () => {
     reportingService.onVisibilityChange();
   });
+  window.addEventListener('blur', () => {
+    reportingService.onFocusChange();
+  });
+  window.addEventListener('focus', () => {
+    reportingService.onFocusChange();
+  });
 }
 
 export function initClickReporter(reportingService: ReportingService) {
@@ -203,6 +212,62 @@
   });
 }
 
+/**
+ * Reports generic user interaction every x seconds to detect, if the user is
+ * present and is using the application somehow. If you just look at
+ * `document.visibilityState`, then the user may have left the browser open
+ * without locking the screen. So it helps to know whether some interaction is
+ * actually happening.
+ */
+export class InteractionReporter implements Finalizable {
+  /** Accumulates event names until the next round of interaction reporting. */
+  private interactionEvents = new Set<string>();
+
+  /** Allows clearing the interval timer. Mostly useful for tests. */
+  private intervalId?: number;
+
+  constructor(
+    private readonly reportingService: ReportingService,
+    private readonly reportingIntervalMs = 10 * 1000
+  ) {
+    const events = ['mousemove', 'scroll', 'wheel', 'keydown', 'pointerdown'];
+    for (const eventName of events) {
+      document.addEventListener(eventName, () =>
+        this.interactionEvents.add(eventName)
+      );
+    }
+
+    this.intervalId = window.setInterval(
+      () => this.report(),
+      this.reportingIntervalMs
+    );
+  }
+
+  finalize() {
+    window.clearInterval(this.intervalId);
+  }
+
+  private report() {
+    const active = this.interactionEvents.size > 0;
+    if (active) {
+      this.reportingService.reportInteraction(Interaction.USER_ACTIVE, {
+        events: [...this.interactionEvents],
+      });
+    } else if (document.visibilityState === 'visible') {
+      this.reportingService.reportInteraction(Interaction.USER_PASSIVE, {});
+    }
+    this.interactionEvents.clear();
+  }
+}
+
+let interactionReporter: InteractionReporter;
+
+export function initInteractionReporter(reportingService: ReportingService) {
+  if (!interactionReporter) {
+    interactionReporter = new InteractionReporter(reportingService);
+  }
+}
+
 export function initWebVitals(reportingService: ReportingService) {
   function reportWebVitalMetric(name: Timing, metric: Metric) {
     let score = metric.value;
@@ -470,6 +535,20 @@
     this._reportNavResTimes();
   }
 
+  onFocusChange() {
+    this.reporter(
+      LIFECYCLE.TYPE,
+      LIFECYCLE.CATEGORY.VISIBILITY,
+      LifeCycle.FOCUS,
+      undefined,
+      {
+        isVisible: document.visibilityState === 'visible',
+        hasFocus: document.hasFocus(),
+      },
+      false
+    );
+  }
+
   onVisibilityChange() {
     this.hiddenDurationTimer.onVisibilityChange();
     let eventName;
@@ -486,6 +565,8 @@
         undefined,
         {
           hiddenDurationMs: this.hiddenDurationTimer.hiddenDurationMs,
+          isVisible: document.visibilityState === 'visible',
+          hasFocus: document.hasFocus(),
         },
         false
       );
@@ -522,6 +603,7 @@
 
   beforeLocationChanged() {
     for (const prop of Object.keys(this._baselines)) {
+      if (LOCATION_CHANGE_OK_TIMERS.includes(prop)) continue;
       delete this._baselines[prop];
     }
     this.time(Timing.CHANGE_DISPLAYED);
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 d4efbcc..fb1f0c3 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -7,7 +7,7 @@
 import {EventDetails} from '../../api/reporting';
 import {PluginApi} from '../../api/plugin';
 import {Execution, Interaction} from '../../constants/reporting';
-import {Finalizable} from '../registry';
+import {Finalizable} from '../../types/types';
 
 export class MockTimer implements Timer {
   end(): this {
@@ -44,6 +44,9 @@
   onVisibilityChange: () => {
     log('onVisibilityChange');
   },
+  onFocusChange: () => {
+    log('onFocusChange');
+  },
   pluginLoaded: () => {},
   pluginsLoaded: () => {},
   pluginsFailed: () => {},
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
index 9c5e20d..2d3dfe2 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
@@ -8,11 +8,14 @@
   GrReporting,
   DEFAULT_STARTUP_TIMERS,
   initErrorReporter,
+  InteractionReporter,
 } from './gr-reporting_impl';
 import {getAppContext} from '../app-context';
 import {Deduping} from '../../api/reporting';
-import {SinonFakeTimers} from 'sinon';
+import {SinonFakeTimers, SinonStub} from 'sinon';
 import {assert} from '@open-wc/testing';
+import {grReportingMock} from './gr-reporting_mock';
+import {Interaction} from '../../constants/reporting';
 
 suite('gr-reporting tests', () => {
   // We have to type as any because we access
@@ -563,3 +566,62 @@
     });
   });
 });
+
+suite('InteractionReporter', () => {
+  let interactionReporter: InteractionReporter;
+  let clock: SinonFakeTimers;
+  let stub: SinonStub;
+  let activeCalls: number[] = [];
+  let passiveCalls: number[] = [];
+
+  setup(() => {
+    clock = sinon.useFakeTimers(0);
+    activeCalls = [];
+    passiveCalls = [];
+    const reporting = grReportingMock;
+    stub = sinon
+      .stub(reporting, 'reportInteraction')
+      .callsFake((interaction: string | Interaction) => {
+        if (interaction === Interaction.USER_ACTIVE) {
+          activeCalls.push(clock.now);
+        }
+        if (interaction === Interaction.USER_PASSIVE) {
+          passiveCalls.push(clock.now);
+        }
+      });
+    interactionReporter = new InteractionReporter(reporting, 1000);
+  });
+
+  teardown(() => {
+    clock.restore();
+    interactionReporter.finalize();
+  });
+
+  test('interaction example', () => {
+    clock.tick(500);
+    clock.tick(1000);
+    document.dispatchEvent(new MouseEvent('mousemove'));
+    clock.tick(1000);
+    document.dispatchEvent(new MouseEvent('mousemove'));
+    document.dispatchEvent(new MouseEvent('scroll'));
+    document.dispatchEvent(new MouseEvent('wheel'));
+    clock.tick(1000);
+    clock.tick(1000);
+    clock.tick(1000);
+    document.dispatchEvent(new MouseEvent('mousemove'));
+    clock.tick(1000);
+    document.dispatchEvent(new MouseEvent('mousemove'));
+    clock.tick(1000);
+
+    assert.sameOrderedMembers(activeCalls, [2000, 3000, 6000, 7000]);
+    assert.sameOrderedMembers(passiveCalls, [1000, 4000, 5000]);
+
+    assert.isUndefined(stub.getCall(0).args[1].events);
+    assert.sameMembers(stub.getCall(1).args[1].events, ['mousemove']);
+    assert.sameMembers(stub.getCall(2).args[1].events, [
+      'mousemove',
+      'scroll',
+      'wheel',
+    ]);
+  });
+});
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
index edbd6a3..1657c23 100644
--- 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
@@ -20,12 +20,8 @@
 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 {listChangesOptionsToHex} from '../../utils/change-util';
 import {assertNever, hasOwnProperty} from '../../utils/common-util';
 import {AuthService} from '../gr-auth/gr-auth';
 import {
@@ -119,6 +115,8 @@
   UrlEncodedCommentId,
   FixReplacementInfo,
   DraftInfo,
+  ListChangesOption,
+  ReviewResult,
 } from '../../types/common';
 import {
   DiffInfo,
@@ -140,16 +138,19 @@
   ReviewerState,
 } from '../../constants/constants';
 import {firePageError, fireServerError} from '../../utils/event-util';
-import {AuthRequestInit, ParsedChangeInfo} from '../../types/types';
+import {
+  AuthRequestInit,
+  Finalizable,
+  ParsedChangeInfo,
+} from '../../types/types';
 import {ErrorCallback} from '../../api/rest';
 import {addDraftProp} from '../../utils/comment-util';
 import {BaseScheduler, Scheduler} from '../scheduler/scheduler';
 import {MaxInFlightScheduler} from '../scheduler/max-in-flight-scheduler';
 import {escapeAndWrapSearchOperatorValue} from '../../utils/string-util';
+import {FlagsService, KnownExperimentId} from '../flags/flags';
 
 const MAX_PROJECT_RESULTS = 25;
-export const PROBE_PATH = '/Documentation/index.html';
-export const DOCS_BASE_PATH = '/Documentation';
 
 const Requests = {
   SEND_DIFF_DRAFT: 'sendDiffDraft',
@@ -167,6 +168,7 @@
 let fetchPromisesCache = new FetchPromisesCache(); // Shared across instances.
 let pendingRequest: {[promiseName: string]: Array<Promise<unknown>>} = {}; // Shared across instances.
 let grEtagDecorator = new GrEtagDecorator(); // Shared across instances.
+// TODO: consider changing this to Map()
 let projectLookup: {[changeNum: string]: Promise<RepoName | undefined>} = {}; // Shared across instances.
 
 function suppress404s(res?: Response | null) {
@@ -286,8 +288,6 @@
 
   readonly _etags = grEtagDecorator; // Shared across instances.
 
-  getDocsBaseUrlCachedPromise: Promise<string | null> | undefined;
-
   // readonly, but set in tests.
   _projectLookup = projectLookup; // Shared across instances.
 
@@ -298,7 +298,10 @@
   // Used to serialize requests for certain RPCs
   readonly _serialScheduler: Scheduler<Response>;
 
-  constructor(private readonly authService: AuthService) {
+  constructor(
+    private readonly authService: AuthService,
+    private readonly flagService: FlagsService
+  ) {
     this._restApiHelper = new GrRestApiHelper(
       this._cache,
       this.authService,
@@ -311,7 +314,9 @@
 
   finalize() {}
 
-  _fetchSharedCacheURL(req: FetchJSONRequest): Promise<ParsedJSON | undefined> {
+  _fetchSharedCacheURL(
+    req: FetchJSONRequest
+  ): Promise<AccountDetailInfo | ParsedJSON | undefined> {
     // Cache is shared across instances
     return this._restApiHelper.fetchCacheURL(req);
   }
@@ -762,6 +767,14 @@
     }) as Promise<AccountExternalIdInfo[] | undefined>;
   }
 
+  deleteAccount() {
+    return this._restApiHelper.send({
+      method: HttpMethod.DELETE,
+      url: '/accounts/self',
+      reportUrlAsIs: true,
+    });
+  }
+
   deleteAccountIdentity(id: string[]) {
     return this._restApiHelper.send({
       method: HttpMethod.POST,
@@ -783,11 +796,35 @@
     }) as Promise<AccountDetailInfo | undefined>;
   }
 
-  getAccountEmails() {
-    return this._fetchSharedCacheURL({
-      url: '/accounts/self/emails',
-      reportUrlAsIs: true,
-    }) as Promise<EmailInfo[] | undefined>;
+  async getAccountEmails() {
+    const isloggedIn = await this.getLoggedIn();
+    if (isloggedIn) {
+      return this._fetchSharedCacheURL({
+        url: '/accounts/self/emails',
+        reportUrlAsIs: true,
+      }) as Promise<EmailInfo[] | undefined>;
+    } else return;
+  }
+
+  getAccountEmailsFor(email: string, errFn?: ErrorCallback) {
+    return this.getLoggedIn()
+      .then(isLoggedIn => {
+        if (isLoggedIn) {
+          return this.getAccountCapabilities();
+        } else {
+          return undefined;
+        }
+      })
+      .then((capabilities: AccountCapabilityInfo | undefined) => {
+        if (capabilities && capabilities.viewSecondaryEmails) {
+          return this._fetchSharedCacheURL({
+            url: '/accounts/' + email + '/emails',
+            reportUrlAsIs: true,
+            errFn,
+          }) as Promise<EmailInfo[] | undefined>;
+        }
+        return undefined;
+      });
   }
 
   addAccountEmail(email: string): Promise<Response> {
@@ -1025,7 +1062,7 @@
   /**
    * Construct the uri to get list of changes.
    *
-   * If options is undefined then default options (see _getChangesOptionsHex) is
+   * If options is undefined then default options (see getListChangesOptionsHex) is
    * used.
    */
   getRequestForGetChanges(
@@ -1034,7 +1071,7 @@
     offset?: 'n,z' | number,
     options?: string
   ) {
-    options = options || this._getChangesOptionsHex();
+    options = options || this.getListChangesOptionsHex();
     if (offset === 'n,z') {
       offset = 0;
     }
@@ -1050,7 +1087,7 @@
     }
     const request = {
       url: '/changes/',
-      params,
+      params: {...params, 'allow-incomplete-results': true},
       reportUrlAsIs: true,
     };
     return request;
@@ -1059,7 +1096,7 @@
   /**
    * For every query fetches the matching changes.
    *
-   * If options is undefined then default options (see _getChangesOptionsHex) is
+   * If options is undefined then default options (see getListChangesOptionsHex) is
    * used.
    */
   getChangesForMultipleQueries(
@@ -1106,7 +1143,7 @@
   /**
    * Fetches changes that match the query.
    *
-   * If options is undefined then default options (see _getChangesOptionsHex) is
+   * If options is undefined then default options (see getListChangesOptionsHex) is
    * used.
    */
   getChanges(
@@ -1181,28 +1218,27 @@
     );
   }
 
-  getChangeDetail(
+  async 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)
-      );
-    });
+    if (!changeNum) return;
+    const optionsHex = await this.getChangeOptionsHex();
+
+    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() {
+  private getListChangesOptionsHex() {
     if (
       window.DEFAULT_DETAIL_HEXES &&
       window.DEFAULT_DETAIL_HEXES.dashboardPage
@@ -1219,21 +1255,24 @@
     return listChangesOptionsToHex(...options);
   }
 
-  _getChangeOptionsHex(config?: ServerInfo) {
-    if (
-      window.DEFAULT_DETAIL_HEXES &&
-      window.DEFAULT_DETAIL_HEXES.changePage &&
-      (!config || !(config.receive && config.receive.enable_signed_push))
-    ) {
+  async getChangeOptionsHex(): Promise<string> {
+    if (window.DEFAULT_DETAIL_HEXES && window.DEFAULT_DETAIL_HEXES.changePage) {
       return window.DEFAULT_DETAIL_HEXES.changePage;
     }
+    return listChangesOptionsToHex(...(await this.getChangeOptions()));
+  }
+
+  async getChangeOptions(): Promise<number[]> {
+    const config = await this.getConfig(false);
 
     // This list MUST be kept in sync with
-    // ChangeIT#changeDetailsDoesNotRequireIndex
+    // ChangeIT#changeDetailsDoesNotRequireIndex and IndexPreloadingUtil#CHANGE_DETAIL_OPTIONS
+    // This list MUST be kept in sync with getResponseFormatOptions
     const options = [
       ListChangesOption.ALL_COMMITS,
       ListChangesOption.ALL_REVISIONS,
       ListChangesOption.CHANGE_ACTIONS,
+      ListChangesOption.DETAILED_ACCOUNTS,
       ListChangesOption.DETAILED_LABELS,
       ListChangesOption.DOWNLOAD_COMMANDS,
       ListChangesOption.MESSAGES,
@@ -1242,10 +1281,41 @@
       ListChangesOption.SKIP_DIFFSTAT,
       ListChangesOption.SUBMIT_REQUIREMENTS,
     ];
+    if (this.flagService.isEnabled(KnownExperimentId.REVISION_PARENTS_DATA)) {
+      options.push(ListChangesOption.PARENTS);
+    }
     if (config?.receive?.enable_signed_push) {
       options.push(ListChangesOption.PUSH_CERTIFICATES);
     }
-    return listChangesOptionsToHex(...options);
+    return options;
+  }
+
+  async getResponseFormatOptions(): Promise<string[]> {
+    const config = await this.getConfig(false);
+
+    // This list MUST be kept in sync with
+    // ChangeIT#changeDetailsDoesNotRequireIndex and IndexPreloadingUtil#CHANGE_DETAIL_OPTIONS
+    // This list MUST be kept in sync with getChangeOptions
+    const options = [
+      'ALL_COMMITS',
+      'ALL_REVISIONS',
+      'CHANGE_ACTIONS',
+      'DETAILED_LABELS',
+      'DETAILED_ACCOUNTS',
+      'DOWNLOAD_COMMANDS',
+      'MESSAGES',
+      'SUBMITTABLE',
+      'WEB_LINKS',
+      'SKIP_DIFFSTAT',
+      'SUBMIT_REQUIREMENTS',
+    ];
+    if (this.flagService.isEnabled(KnownExperimentId.REVISION_PARENTS_DATA)) {
+      options.push('PARENTS');
+    }
+    if (config?.receive?.enable_signed_push) {
+      options.push('PUSH_CERTIFICATES');
+    }
+    return options;
   }
 
   /**
@@ -1714,7 +1784,7 @@
     });
   }
 
-  getSuggestedAccounts(
+  async getSuggestedAccounts(
     inputVal: string,
     n?: number,
     canSee?: NumericChangeId,
@@ -1732,7 +1802,8 @@
       queryParams.push(`${escapeAndWrapSearchOperatorValue(inputVal)}`);
     }
     if (canSee) {
-      queryParams.push(`cansee:${canSee}`);
+      const project = await this.getFromProjectLookup(canSee);
+      queryParams.push(`cansee:${project}~${canSee}`);
     }
     if (filterActive) {
       queryParams.push('is:active');
@@ -1954,37 +2025,36 @@
     });
   }
 
-  saveChangeReview(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    review: ReviewInput
-  ): Promise<Response>;
-
-  saveChangeReview(
+  async saveChangeReview(
     changeNum: NumericChangeId,
     patchNum: PatchSetNum,
     review: ReviewInput,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-
-  saveChangeReview(
-    changeNum: NumericChangeId,
-    patchNum: PatchSetNum,
-    review: ReviewInput,
-    errFn?: ErrorCallback
+    errFn?: ErrorCallback,
+    fetchDetail?: boolean
   ) {
+    if (fetchDetail) {
+      review.response_format_options = await this.getResponseFormatOptions();
+    }
     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,
-      })
-    );
+    return Promise.all(promises)
+      .then(([, url]) =>
+        this._restApiHelper.send({
+          method: HttpMethod.POST,
+          url,
+          body: review,
+          errFn,
+          parseResponse: true,
+        })
+      )
+      .then(payload => {
+        if (!payload) {
+          return undefined;
+        }
+        return payload as unknown as ReviewResult;
+      });
   }
 
   getChangeEdit(changeNum?: NumericChangeId): Promise<EditInfo | undefined> {
@@ -3088,24 +3158,38 @@
   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];
-      });
+  ): Promise<ChangeInfo | undefined> {
+    if (changeNum in this._projectLookup) {
+      // _projectLookup can only store NumericChangeId, so we are sure that
+      // changeNum is NumericChangeId in this case.
+      return this._changeBaseURL(changeNum as NumericChangeId).then(url =>
+        this._restApiHelper.fetchJSON(
+          {
+            url,
+            errFn,
+            anonymizedUrl: '/changes/*~*',
+          },
+          /* noAcceptHeader */ true
+        )
+      ) as Promise<ChangeInfo | undefined>;
+    } else {
+      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 undefined;
+          }
+          return changeInfos[0];
+        });
+    }
   }
 
   /**
@@ -3319,26 +3403,6 @@
     }) as Promise<DashboardInfo | undefined>;
   }
 
-  /**
-   * Get the docs base URL from either the server config or by probing.
-   *
-   * @return A promise that resolves with the docs base URL.
-   */
-  getDocsBaseUrl(config: ServerInfo | undefined): Promise<string | null> {
-    if (!this.getDocsBaseUrlCachedPromise) {
-      this.getDocsBaseUrlCachedPromise = new Promise(resolve => {
-        if (config?.gerrit?.doc_url) {
-          resolve(config.gerrit.doc_url);
-        } else {
-          this.probePath(getBaseUrl() + PROBE_PATH).then(ok => {
-            resolve(ok ? getBaseUrl() + DOCS_BASE_PATH : null);
-          });
-        }
-      });
-    }
-    return this.getDocsBaseUrlCachedPromise;
-  }
-
   getDocumentationSearches(filter: string): Promise<DocResult[] | undefined> {
     filter = filter.trim();
     const encodedFilter = encodeURIComponent(filter);
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
index 7abcb0d..fe52529 100644
--- 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
@@ -12,15 +12,11 @@
   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 {listChangesOptionsToHex} from '../../utils/change-util';
 import {
   createAccountDetailWithId,
   createChange,
   createComment,
-  createGerritInfo,
   createParsedChange,
   createServerInfo,
 } from '../../test/test-data-generators';
@@ -47,6 +43,7 @@
   EditPreferencesInfo,
   Hashtag,
   HashtagsInput,
+  ListChangesOption,
   NumericChangeId,
   PARENT,
   ParsedJSON,
@@ -56,7 +53,6 @@
   RevisionId,
   RevisionPatchSetNum,
   RobotCommentInfo,
-  ServerInfo,
   Timestamp,
   UrlEncodedCommentId,
 } from '../../types/common';
@@ -64,6 +60,7 @@
 import {AuthService} from '../gr-auth/gr-auth';
 import {GrAuthMock} from '../gr-auth/gr-auth_mock';
 import {getBaseUrl} from '../../utils/url-util';
+import {FlagsServiceImplementation} from '../flags/flags_impl';
 
 const EXPECTED_QUERY_OPTIONS = listChangesOptionsToHex(
   ListChangesOption.CHANGE_ACTIONS,
@@ -93,7 +90,10 @@
     // fake auth
     authService = new GrAuthMock();
     sinon.stub(authService, 'authCheck').resolves(true);
-    element = new GrRestApiServiceImpl(authService);
+    element = new GrRestApiServiceImpl(
+      authService,
+      new FlagsServiceImplementation()
+    );
 
     element._projectLookup = {};
   });
@@ -349,14 +349,20 @@
 
   suite('getAccountSuggestions', () => {
     let fetchStub: sinon.SinonStub;
+    const testProject = 'testproject';
+    const testChangeNumber = 341682;
     setup(() => {
       fetchStub = sinon
         .stub(element._restApiHelper, 'fetch')
         .resolves(new Response());
+      element.setInProjectLookup(
+        testChangeNumber as NumericChangeId,
+        testProject as RepoName
+      );
     });
 
-    test('url with just email', () => {
-      element.getSuggestedAccounts('bro');
+    test('url with just email', async () => {
+      await element.getSuggestedAccounts('bro');
       assert.isTrue(fetchStub.calledOnce);
       assert.equal(
         fetchStub.firstCall.args[0].url,
@@ -364,26 +370,30 @@
       );
     });
 
-    test('url with email and canSee changeId', () => {
-      element.getSuggestedAccounts('bro', undefined, 341682 as NumericChangeId);
+    test('url with email and canSee changeId', async () => {
+      await element.getSuggestedAccounts(
+        'bro',
+        undefined,
+        testChangeNumber as NumericChangeId
+      );
       assert.isTrue(fetchStub.calledOnce);
       assert.equal(
         fetchStub.firstCall.args[0].url,
-        `${getBaseUrl()}/accounts/?o=DETAILS&q=%22bro%22%20and%20cansee%3A341682`
+        `${getBaseUrl()}/accounts/?o=DETAILS&q=%22bro%22%20and%20cansee%3A${testProject}~${testChangeNumber}`
       );
     });
 
-    test('url with email and canSee changeId and isActive', () => {
-      element.getSuggestedAccounts(
+    test('url with email and canSee changeId and isActive', async () => {
+      await element.getSuggestedAccounts(
         'bro',
         undefined,
-        341682 as NumericChangeId,
+        testChangeNumber as NumericChangeId,
         true
       );
       assert.isTrue(fetchStub.calledOnce);
       assert.equal(
         fetchStub.firstCall.args[0].url,
-        `${getBaseUrl()}/accounts/?o=DETAILS&q=%22bro%22%20and%20cansee%3A341682%20and%20is%3Aactive`
+        `${getBaseUrl()}/accounts/?o=DETAILS&q=%22bro%22%20and%20cansee%3A${testProject}~${testChangeNumber}%20and%20is%3Aactive`
       );
     });
   });
@@ -1190,17 +1200,17 @@
     const repo = 'test-repo' as RepoName;
 
     test('getChange fails to yield a project', async () => {
-      const promise = mockPromise<null>();
+      const promise = mockPromise<undefined>();
       sinon.stub(element, 'getChange').returns(promise);
 
       const projectLookup = element.getFromProjectLookup(changeNum);
-      promise.resolve(null);
+      promise.resolve(undefined);
 
       assert.isUndefined(await projectLookup);
     });
 
     test('getChange succeeds with project', async () => {
-      const promise = mockPromise<null | ChangeInfo>();
+      const promise = mockPromise<undefined | ChangeInfo>();
       sinon.stub(element, 'getChange').returns(promise);
 
       const projectLookup = element.getFromProjectLookup(changeNum);
@@ -1211,12 +1221,12 @@
     });
 
     test('getChange fails, but a setInProjectLookup() call is used as fallback', async () => {
-      const promise = mockPromise<null>();
+      const promise = mockPromise<undefined>();
       sinon.stub(element, 'getChange').returns(promise);
 
       const projectLookup = element.getFromProjectLookup(changeNum);
       element.setInProjectLookup(changeNum, repo);
-      promise.resolve(null);
+      promise.resolve(undefined);
 
       assert.equal(await projectLookup, repo);
     });
@@ -1627,51 +1637,4 @@
       anonymizedUrl: '/accounts/self/starred.changes/*',
     });
   });
-
-  suite('getDocsBaseUrl tests', () => {
-    test('null config', async () => {
-      const probePathMock = sinon.stub(element, 'probePath').resolves(true);
-      const docsBaseUrl = await element.getDocsBaseUrl(undefined);
-      assert.equal(
-        probePathMock.lastCall.args[0],
-        `${getBaseUrl()}/Documentation/index.html`
-      );
-      assert.equal(docsBaseUrl, `${getBaseUrl()}/Documentation`);
-    });
-
-    test('no doc config', async () => {
-      const probePathMock = sinon.stub(element, 'probePath').resolves(true);
-      const config: ServerInfo = {
-        ...createServerInfo(),
-        gerrit: createGerritInfo(),
-      };
-      const docsBaseUrl = await element.getDocsBaseUrl(config);
-      assert.equal(
-        probePathMock.lastCall.args[0],
-        `${getBaseUrl()}/Documentation/index.html`
-      );
-      assert.equal(docsBaseUrl, `${getBaseUrl()}/Documentation`);
-    });
-
-    test('has doc config', async () => {
-      const probePathMock = sinon.stub(element, 'probePath').resolves(true);
-      const config: ServerInfo = {
-        ...createServerInfo(),
-        gerrit: {...createGerritInfo(), doc_url: 'foobar'},
-      };
-      const docsBaseUrl = await element.getDocsBaseUrl(config);
-      assert.isFalse(probePathMock.called);
-      assert.equal(docsBaseUrl, 'foobar');
-    });
-
-    test('no probe', async () => {
-      const probePathMock = sinon.stub(element, 'probePath').resolves(false);
-      const docsBaseUrl = await element.getDocsBaseUrl(undefined);
-      assert.equal(
-        probePathMock.lastCall.args[0],
-        `${getBaseUrl()}/Documentation/index.html`
-      );
-      assert.isNotOk(docsBaseUrl);
-    });
-  });
 });
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 d8bf276..546f06a0 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
@@ -4,7 +4,6 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {HttpMethod} from '../../constants/constants';
-import {Finalizable} from '../registry';
 import {
   AccountCapabilityInfo,
   AccountDetailInfo,
@@ -91,13 +90,14 @@
   UrlEncodedCommentId,
   UserId,
   DraftInfo,
+  ReviewResult,
 } from '../../types/common';
 import {
   DiffInfo,
   DiffPreferencesInfo,
   IgnoreWhitespaceType,
 } from '../../types/diff';
-import {ParsedChangeInfo} from '../../types/types';
+import {Finalizable, ParsedChangeInfo} from '../../types/types';
 import {ErrorCallback} from '../../api/rest';
 
 export type CancelConditionCallback = () => boolean;
@@ -123,6 +123,7 @@
   ): Promise<AccountCapabilityInfo | undefined>;
   getExternalIds(): Promise<AccountExternalIdInfo[] | undefined>;
   deleteAccountIdentity(id: string[]): Promise<unknown>;
+  deleteAccount(): Promise<unknown>;
   getRepos(
     filter: string | undefined,
     reposPerPage: number,
@@ -204,11 +205,16 @@
 
   /**
    * Given a changeNum, gets the change.
+   *
+   * If the project is known for the specified changeNum uses
+   * /changes/{project}~{change} api.
+   * Otherwise, calls /changes/q={changeNum}. In this case the result can be
+   * stale as this API uses index.
    */
   getChange(
     changeNum: ChangeId | NumericChangeId,
     errFn?: ErrorCallback
-  ): Promise<ChangeInfo | null>;
+  ): Promise<ChangeInfo | undefined>;
 
   savePreferences(
     prefs: PreferencesInput
@@ -223,6 +229,10 @@
   saveEditPreferences(prefs: EditPreferencesInfo): Promise<Response>;
 
   getAccountEmails(): Promise<EmailInfo[] | undefined>;
+  getAccountEmailsFor(
+    email: string,
+    errFn?: ErrorCallback
+  ): Promise<EmailInfo[] | undefined>;
   deleteAccountEmail(email: string): Promise<Response>;
   setPreferredAccountEmail(email: string): Promise<void>;
 
@@ -351,20 +361,10 @@
   saveChangeReview(
     changeNum: ChangeId | NumericChangeId,
     patchNum: RevisionId,
-    review: ReviewInput
-  ): Promise<Response>;
-  saveChangeReview(
-    changeNum: ChangeId | NumericChangeId,
-    patchNum: RevisionId,
     review: ReviewInput,
-    errFn: ErrorCallback
-  ): Promise<Response | undefined>;
-  saveChangeReview(
-    changeNum: ChangeId | NumericChangeId,
-    patchNum: RevisionId,
-    review: ReviewInput,
-    errFn?: ErrorCallback
-  ): Promise<Response>;
+    errFn?: ErrorCallback,
+    fetch_detail?: boolean
+  ): Promise<ReviewResult | undefined>;
 
   getChangeEdit(changeNum?: NumericChangeId): Promise<EditInfo | undefined>;
 
@@ -374,8 +374,6 @@
     endpoint: string
   ): Promise<string>;
 
-  getDocsBaseUrl(config?: ServerInfo): Promise<string | null>;
-
   createChange(
     repo: RepoName,
     branch: BranchName,
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
index 842dace..34187fe 100644
--- 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
@@ -3,25 +3,24 @@
  * 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,
+  SuggestedReviewerAccountInfo,
   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 {
+  accountKey,
+  getSuggestedReviewerName,
+  isAccountSuggestion,
+} from '../../utils/account-util';
 import {
   AccountId,
   ChangeInfo,
@@ -96,30 +95,11 @@
   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');
+    const name = getSuggestedReviewerName(suggestion, this.config);
+    const value = isAccountSuggestion(suggestion)
+      ? ({account: suggestion, count: 1} as SuggestedReviewerAccountInfo)
+      : suggestion;
+    return {name, value};
   }
 
   private getSuggestionsForChange(
@@ -160,7 +140,3 @@
   }
   return undefined;
 }
-
-function isAccountSuggestion(s: Suggestion): s is AccountInfo {
-  return (s as AccountInfo)._account_id !== undefined;
-}
diff --git a/polygerrit-ui/app/services/highlight/highlight-service.ts b/polygerrit-ui/app/services/highlight/highlight-service.ts
index d10d875..d59431b 100644
--- a/polygerrit-ui/app/services/highlight/highlight-service.ts
+++ b/polygerrit-ui/app/services/highlight/highlight-service.ts
@@ -11,10 +11,10 @@
   SyntaxWorkerMessageType,
   SyntaxLayerLine,
 } from '../../types/syntax-worker-api';
+import {Finalizable} from '../../types/types';
 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 ?? ''
diff --git a/polygerrit-ui/app/services/registry.ts b/polygerrit-ui/app/services/registry.ts
index 48a5241..a88aec3 100644
--- a/polygerrit-ui/app/services/registry.ts
+++ b/polygerrit-ui/app/services/registry.ts
@@ -4,11 +4,7 @@
  * 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;
-}
+import {Finalizable} from '../types/types';
 
 // A factory can take a partially created TContext and generate a property
 // for a given key on that TContext.
diff --git a/polygerrit-ui/app/services/registry_test.ts b/polygerrit-ui/app/services/registry_test.ts
index 639cd64..15d37d9 100644
--- a/polygerrit-ui/app/services/registry_test.ts
+++ b/polygerrit-ui/app/services/registry_test.ts
@@ -3,9 +3,10 @@
  * Copyright 2021 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {create, Finalizable, Registry} from './registry';
+import {create, Registry} from './registry';
 import '../test/common-test-setup';
 import {assert} from '@open-wc/testing';
+import {Finalizable} from '../types/types';
 
 class Foo implements Finalizable {
   constructor(private readonly final: string[]) {}
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
index 5e2cc10..05927f3 100644
--- a/polygerrit-ui/app/services/router/router-model.ts
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {Observable} from 'rxjs';
-import {Model} from '../../models/model';
+import {Model} from '../../models/base/model';
 import {select} from '../../utils/observable-util';
 import {define} from '../../models/dependency';
 
diff --git a/polygerrit-ui/app/services/service-worker-installer.ts b/polygerrit-ui/app/services/service-worker-installer.ts
index b83713c..8c606d4 100644
--- a/polygerrit-ui/app/services/service-worker-installer.ts
+++ b/polygerrit-ui/app/services/service-worker-installer.ts
@@ -15,7 +15,7 @@
 import {LifeCycle} from '../constants/reporting';
 import {ReportingService} from './gr-reporting/gr-reporting';
 import {define} from '../models/dependency';
-import {Model} from '../models/model';
+import {Model} from '../models/base/model';
 import {Observable} from 'rxjs';
 import {select} from '../utils/observable-util';
 
diff --git a/polygerrit-ui/app/services/service-worker-installer_test.ts b/polygerrit-ui/app/services/service-worker-installer_test.ts
index a036289..e13cf19 100644
--- a/polygerrit-ui/app/services/service-worker-installer_test.ts
+++ b/polygerrit-ui/app/services/service-worker-installer_test.ts
@@ -20,19 +20,31 @@
     const userModel = testResolver(userModelToken);
     sinon.stub(flagsService, 'isEnabled').returns(true);
     new ServiceWorkerInstaller(flagsService, reportingService, userModel);
+    // TODO: There is a race-condition betweeen preferences being set here
+    // and being loaded from the rest-api-service when the user-model gets created.
+    // So we explicitly wait for the allow_browser_notifications to be false
+    // before continuing with the test.
+    // Ideally there's a way to wait for models to stabilize.
     const prefs = {
       ...createDefaultPreferences(),
+      allow_browser_notifications: false,
+    };
+    await waitUntilObserved(
+      userModel.preferences$,
+      pref => pref.allow_browser_notifications === false
+    );
+    userModel.setPreferences(prefs);
+
+    const prefs2 = {
+      ...createDefaultPreferences(),
       allow_browser_notifications: true,
     };
-    userModel.setPreferences(prefs);
+    userModel.setPreferences(prefs2);
     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-service.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
index 756c209..db9d8fe 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
@@ -22,10 +22,10 @@
   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';
+import {Finalizable} from '../../types/types';
 
 export {Shortcut, ShortcutSection};
 
diff --git a/polygerrit-ui/app/services/storage/gr-storage.ts b/polygerrit-ui/app/services/storage/gr-storage.ts
index d7eb09a..0b34614 100644
--- a/polygerrit-ui/app/services/storage/gr-storage.ts
+++ b/polygerrit-ui/app/services/storage/gr-storage.ts
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {NumericChangeId} from '../../types/common';
-import {Finalizable} from '../registry';
+import {Finalizable} from '../../types/types';
 
 export interface StorageObject {
   message?: string;
diff --git a/polygerrit-ui/app/services/storage/gr-storage_impl.ts b/polygerrit-ui/app/services/storage/gr-storage_impl.ts
index 7a47e0e..9ddba01 100644
--- a/polygerrit-ui/app/services/storage/gr-storage_impl.ts
+++ b/polygerrit-ui/app/services/storage/gr-storage_impl.ts
@@ -4,9 +4,9 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {StorageObject, StorageService} from './gr-storage';
-import {Finalizable} from '../registry';
 import {NumericChangeId} from '../../types/common';
 import {define} from '../../models/dependency';
+import {Finalizable} from '../../types/types';
 
 export const DURATION_DAY = 24 * 60 * 60 * 1000;
 
diff --git a/polygerrit-ui/app/styles/form-styles.ts b/polygerrit-ui/app/styles/form-styles.ts
new file mode 100644
index 0000000..47f610e03
--- /dev/null
+++ b/polygerrit-ui/app/styles/form-styles.ts
@@ -0,0 +1,49 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css} from 'lit';
+import {grFormStyles} from './gr-form-styles';
+
+export const formStyles = css`
+  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);
+  }
+  /* prettier formatter removes semi-colons after css mixins. */
+  /* prettier-ignore */
+  iron-autogrow-textarea {
+    background-color: inherit;
+    color: var(--primary-text-color);
+    border: 1px solid var(--border-color);
+    border-radius: var(--border-radius);
+    padding: 0;
+    box-sizing: border-box;
+    /* iron-autogrow-textarea has a "-webkit-appearance: textarea" :host
+        css rule, which prevents overriding the border color. Clear that. */
+    -webkit-appearance: none;
+    --iron-autogrow-textarea_-_box-sizing: border-box;
+    --iron-autogrow-textarea_-_padding: var(--spacing-s);
+  }
+  input,
+  textarea,
+  select {
+    font: inherit;
+  }
+`;
+
+const $_documentContainer = document.createElement('template');
+$_documentContainer.innerHTML = `<dom-module id="form-styles">
+  <template>
+    <style>
+    ${grFormStyles.cssText}
+    </style>
+  </template>
+</dom-module>`;
+document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/gr-form-styles.ts b/polygerrit-ui/app/styles/gr-form-styles.ts
index 120b0bd..b5bbe76 100644
--- a/polygerrit-ui/app/styles/gr-form-styles.ts
+++ b/polygerrit-ui/app/styles/gr-form-styles.ts
@@ -5,7 +5,7 @@
  */
 import {css} from 'lit';
 
-export const formStyles = css`
+export const grFormStyles = css`
   .gr-form-styles input {
     background-color: var(--view-background-color);
     color: var(--primary-text-color);
@@ -107,7 +107,7 @@
 $_documentContainer.innerHTML = `<dom-module id="gr-form-styles">
   <template>
     <style>
-    ${formStyles.cssText}
+    ${grFormStyles.cssText}
     </style>
   </template>
 </dom-module>`;
diff --git a/polygerrit-ui/app/styles/gr-modal-styles.ts b/polygerrit-ui/app/styles/gr-modal-styles.ts
index b1bcf51..a8b5dc4 100644
--- a/polygerrit-ui/app/styles/gr-modal-styles.ts
+++ b/polygerrit-ui/app/styles/gr-modal-styles.ts
@@ -28,3 +28,13 @@
     opacity: var(--modal-opacity, 0.6);
   }
 `;
+
+const $_documentContainer = document.createElement('template');
+$_documentContainer.innerHTML = `<dom-module id="gr-modal-styles">
+  <template>
+    <style>
+      ${modalStyles.cssText}
+    </style>
+  </template>
+</dom-module>`;
+document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
index 5a7ca48..1582f6c 100644
--- a/polygerrit-ui/app/styles/shared-styles.ts
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -103,34 +103,9 @@
   *::before {
     box-sizing: border-box;
   }
-  input {
-    background-color: var(--background-color-primary);
-    border: 1px solid var(--border-color);
-    border-radius: var(--border-radius);
-    box-sizing: border-box;
-    color: var(--primary-text-color);
-    margin: 0;
-    padding: var(--spacing-s);
-  }
-  /* prettier formatter removes semi-colons after css mixins. */
-  /* prettier-ignore */
-  iron-autogrow-textarea {
-    background-color: inherit;
-    color: var(--primary-text-color);
-    border: 1px solid var(--border-color);
-    border-radius: var(--border-radius);
-    padding: 0;
-    box-sizing: border-box;
-    /* iron-autogrow-textarea has a "-webkit-appearance: textarea" :host
-        css rule, which prevents overriding the border color. Clear that. */
-    -webkit-appearance: none;
-    --iron-autogrow-textarea_-_box-sizing: border-box;
-    --iron-autogrow-textarea_-_padding: var(--spacing-s);
-  }
   a {
     color: var(--link-color);
   }
-  input,
   textarea,
   select,
   button {
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 0503e4c..7742b1f 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -323,6 +323,7 @@
     --status-wip: #795548;
     --status-private: var(--purple-500);
     --status-conflict: var(--red-600);
+    --status-revert: var(--gray-900);
     --status-revert-created: #e64a19;
     --status-active: var(--blue-700);
     --status-ready: var(--pink-800);
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index dc3d4e9..574e6c2 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -181,6 +181,7 @@
     --status-wip: #bcaaa4;
     --status-private: var(--purple-200);
     --status-conflict: var(--red-300);
+    --status-revert: var(--gray-200);
     --status-revert-created: #ff8a65;
     --status-active: var(--blue-400);
     --status-ready: var(--pink-500);
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index 365bb16..82c1bb9 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -7,7 +7,6 @@
 // https://github.com/Polymer/polymer-resin/issues/9 is resolved.
 import '../scripts/bundled-polymer';
 import {getAppContext} from '../services/app-context';
-import {Finalizable} from '../services/registry';
 import {
   createTestAppContext,
   createTestDependencies,
@@ -37,9 +36,10 @@
   Provider,
 } from '../models/dependency';
 import * as sinon from 'sinon';
-import '../styles/themes/app-theme.ts';
+import '../styles/themes/app-theme';
 import {Creator} from '../services/app-context-init';
 import {pluginLoaderToken} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
+import {Finalizable} from '../types/types';
 
 declare global {
   interface Window {
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 bfa881e..7969264 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -68,20 +68,15 @@
   createCommit,
   createConfig,
   createMergeable,
-  createPreferences,
   createServerInfo,
   createSubmittedTogetherInfo,
 } from '../test-data-generators';
 import {
   createDefaultDiffPrefs,
   createDefaultEditPrefs,
+  createDefaultPreferences,
 } from '../../constants/constants';
 import {ParsedChangeInfo} from '../../types/types';
-import {getBaseUrl} from '../../utils/url-util';
-import {
-  DOCS_BASE_PATH,
-  PROBE_PATH,
-} from '../../services/gr-rest-api/gr-rest-api-impl';
 
 export const grRestApiMock: RestApiService = {
   addAccountEmail(): Promise<Response> {
@@ -123,6 +118,9 @@
   createRepoTag(): Promise<Response> {
     return Promise.resolve(new Response());
   },
+  deleteAccount(): Promise<Response> {
+    return Promise.resolve(new Response());
+  },
   deleteAccountEmail(): Promise<Response> {
     return Promise.resolve(new Response());
   },
@@ -188,6 +186,9 @@
   getAccountEmails(): Promise<EmailInfo[] | undefined> {
     return Promise.resolve([]);
   },
+  getAccountEmailsFor(): Promise<EmailInfo[] | undefined> {
+    return Promise.resolve([]);
+  },
   getAccountGPGKeys(): Promise<Record<string, GpgKeyInfo>> {
     return Promise.resolve({});
   },
@@ -209,7 +210,7 @@
   getCapabilities(): Promise<CapabilityInfoMap | undefined> {
     return Promise.resolve({});
   },
-  getChange(): Promise<ChangeInfo | null> {
+  getChange(): Promise<ChangeInfo | undefined> {
     throw new Error('getChange() not implemented by RestApiMock.');
   },
   getChangeActionURL(): Promise<string> {
@@ -311,16 +312,6 @@
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
     return Promise.resolve({}) as any;
   },
-  getDocsBaseUrl(config?: ServerInfo): Promise<string | null> {
-    if (config?.gerrit?.doc_url) {
-      return Promise.resolve(config.gerrit.doc_url);
-    } else {
-      return this.probePath(getBaseUrl() + PROBE_PATH).then(ok =>
-        Promise.resolve(ok ? getBaseUrl() + DOCS_BASE_PATH : null)
-      );
-    }
-    return Promise.resolve('');
-  },
   getDocumentationSearches(): Promise<DocResult[] | undefined> {
     return Promise.resolve([]);
   },
@@ -378,8 +369,7 @@
     return Promise.resolve({});
   },
   getPreferences(): Promise<PreferencesInfo | undefined> {
-    // TODO: Use createDefaultPreferences() instead.
-    return Promise.resolve(createPreferences());
+    return Promise.resolve(createDefaultPreferences());
   },
   getProjectConfig(): Promise<ConfigInfo | undefined> {
     return Promise.resolve(createConfig());
@@ -479,7 +469,7 @@
     return Promise.resolve(new Response());
   },
   saveChangeReview() {
-    return Promise.resolve(new Response());
+    return Promise.resolve({});
   },
   saveChangeStarred(): Promise<Response> {
     return Promise.resolve(new Response());
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 026e3b5..6b9f507 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -5,7 +5,7 @@
  */
 
 // Init app context before any other imports
-import {create, Registry, Finalizable} from '../services/registry';
+import {create, Registry} from '../services/registry';
 import {AppContext} from '../services/app-context';
 import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
 import {grRestApiMock} from './mocks/gr-rest-api_mock';
@@ -18,6 +18,11 @@
 import {DependencyToken} from '../models/dependency';
 import {storageServiceToken} from '../services/storage/gr-storage_impl';
 import {highlightServiceToken} from '../services/highlight/highlight-service';
+import {
+  diffModelToken,
+  DiffModel,
+} from '../embed/diff/gr-diff-model/gr-diff-model';
+import {Finalizable} from '../types/types';
 
 export function createTestAppContext(): AppContext & Finalizable {
   const appRegistry: Registry<AppContext> = {
@@ -49,5 +54,6 @@
     highlightServiceToken,
     () => new MockHighlightService(appContext.reportingService)
   );
+  dependencies.set(diffModelToken, () => new DiffModel(document));
   return dependencies;
 }
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index d2cba9a..d941c9f 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -104,7 +104,7 @@
   SubmitRequirementStatus,
 } from '../api/rest-api';
 import {CheckResult, CheckRun, RunResult} from '../models/checks/checks-model';
-import {Category, RunStatus} from '../api/checks';
+import {Category, Fix, Link, LinkIcon, RunStatus} from '../api/checks';
 import {DiffInfo} from '../api/diff';
 import {SearchViewState} from '../models/views/search';
 import {ChangeChildView, ChangeViewState} from '../models/views/change';
@@ -112,7 +112,7 @@
 import {GroupViewState} from '../models/views/group';
 import {RepoDetailView, RepoViewState} from '../models/views/repo';
 import {AdminChildView, AdminViewState} from '../models/views/admin';
-import {DashboardViewState} from '../models/views/dashboard';
+import {DashboardType, DashboardViewState} from '../models/views/dashboard';
 
 const TEST_DEFAULT_EXPRESSION = 'label:Verified=MAX -label:Verified=MIN';
 export const TEST_PROJECT_NAME: RepoName = 'test-project' as RepoName;
@@ -744,6 +744,7 @@
 export function createDashboardViewState(): DashboardViewState {
   return {
     view: GerritView.DASHBOARD,
+    type: DashboardType.USER,
     user: 'self',
   };
 }
@@ -1129,6 +1130,8 @@
     pluginName: 'test-plugin-name',
     summary: 'This is the test summary.',
     message: 'This is the test message.',
+    status: RunStatus.COMPLETED,
+    attemptDetails: [{attempt: 'latest'}],
   };
 }
 
@@ -1143,6 +1146,29 @@
   };
 }
 
+export function createCheckFix(partial: Partial<Fix> = {}): Fix {
+  return {
+    description: 'this is a test fix',
+    replacements: [
+      {
+        path: 'testpath',
+        range: createRange(),
+        replacement: 'testreplacement',
+      },
+    ],
+    ...partial,
+  };
+}
+
+export function createCheckLink(partial: Partial<Link> = {}): Link {
+  return {
+    url: 'http://test/url',
+    primary: true,
+    icon: LinkIcon.EXTERNAL,
+    ...partial,
+  };
+}
+
 export function createDetailedLabelInfo(): DetailedLabelInfo {
   return {
     values: {
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 21150eb..6e20dd4 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -18,8 +18,7 @@
 import {PageContext} from '../elements/core/gr-router/gr-page';
 import {waitUntil} from '../utils/async-util';
 export {query, queryAll, queryAndAssert} from '../utils/common-util';
-export {waitUntil} from '../utils/async-util';
-export {mockPromise} from '../utils/async-util';
+export {mockPromise, waitUntil} from '../utils/async-util';
 export type {MockPromise} from '../utils/async-util';
 
 export function isHidden(el: Element | undefined | null) {
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index b148780..9992f8b 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -9,7 +9,6 @@
   SubmitType,
   InheritedBooleanInfoConfiguredValue,
   PermissionAction,
-  CommentSide,
   AppTheme,
   DateFormat,
   TimeFormat,
@@ -42,8 +41,10 @@
   ChangeMessageId,
   ChangeMessageInfo,
   ChangeSubmissionId,
+  CommentInfo,
   CommentLinkInfo,
   CommentLinks,
+  CommentSide,
   CommitId,
   CommitInfo,
   ConfigArrayParameterInfo,
@@ -51,7 +52,10 @@
   ConfigListParameterInfo,
   ConfigParameterInfo,
   ConfigParameterInfoBase,
+  ContextLine,
   ContributorAgreementInfo,
+  CustomKey,
+  CustomKeyedValues,
   DetailedLabelInfo,
   DownloadInfo,
   DownloadSchemeInfo,
@@ -106,6 +110,7 @@
   SuggestInfo,
   Timestamp,
   TopicName,
+  UrlEncodedCommentId,
   UrlEncodedRepoName,
   UserConfigInfo,
   VotingRangeInfo,
@@ -116,7 +121,7 @@
   CommentRange,
 } from '../api/rest-api';
 import {DiffInfo, IgnoreWhitespaceType} from './diff';
-import {LineNumber} from '../api/diff';
+import {PatchRange, LineNumber} from '../api/diff';
 
 export type {
   AccountId,
@@ -139,6 +144,7 @@
   ChangeMessageId,
   ChangeMessageInfo,
   ChangeSubmissionId,
+  CommentInfo,
   CommentLinkInfo,
   CommentLinks,
   CommentRange,
@@ -149,6 +155,7 @@
   ConfigListParameterInfo,
   ConfigParameterInfo,
   ConfigParameterInfoBase,
+  ContextLine,
   ContributorAgreementInfo,
   DetailedLabelInfo,
   DownloadInfo,
@@ -177,6 +184,7 @@
   MaxObjectSizeLimitInfo,
   NumericChangeId,
   ParentCommitInfo,
+  PatchRange,
   PatchSetNum,
   PatchSetNumber,
   PluginConfigInfo,
@@ -200,6 +208,7 @@
   SuggestInfo,
   Timestamp,
   TopicName,
+  UrlEncodedCommentId,
   UrlEncodedRepoName,
   UserConfigInfo,
   VotingRangeInfo,
@@ -231,9 +240,6 @@
 // The UUID of the suggested fix.
 export type FixId = BrandType<string, '_fixId'>;
 
-// The URL encoded UUID of the comment
-export type UrlEncodedCommentId = BrandType<string, '_urlEncodedCommentId'>;
-
 // The ID of the dashboard, in the form of '<ref>:<path>'
 export type DashboardId = BrandType<string, '_dahsboardId'>;
 
@@ -247,6 +253,87 @@
 
 export type UserId = AccountId | GroupId | EmailAddress;
 
+export type DiffPageSidebar = 'NONE' | `plugin-${string}`;
+
+// Must be kept in sync with the ListChangesOption enum.
+// See: java/com/google/gerrit/extensions/client/ListChangesOption.java
+export const ListChangesOption = {
+  LABELS: 0,
+  DETAILED_LABELS: 8,
+
+  // Return information on the current patch set of the change.
+  CURRENT_REVISION: 1,
+  ALL_REVISIONS: 2,
+
+  // If revisions are included, parse the commit object.
+  CURRENT_COMMIT: 3,
+  ALL_COMMITS: 4,
+
+  // If a patch set is included, include the files of the patch set.
+  CURRENT_FILES: 5,
+  ALL_FILES: 6,
+
+  // If accounts are included, include detailed account info.
+  DETAILED_ACCOUNTS: 7,
+
+  // Include messages associated with the change.
+  MESSAGES: 9,
+
+  // Include allowed actions client could perform.
+  CURRENT_ACTIONS: 10,
+
+  // Set the reviewed boolean for the caller.
+  REVIEWED: 11,
+
+  // Include download commands for the caller.
+  DOWNLOAD_COMMANDS: 13,
+
+  // Include patch set weblinks.
+  WEB_LINKS: 14,
+
+  // Include consistency check results.
+  CHECK: 15,
+
+  // Include allowed change actions client could perform.
+  CHANGE_ACTIONS: 16,
+
+  // Include a copy of commit messages including review footers.
+  COMMIT_FOOTERS: 17,
+
+  // Include push certificate information along with any patch sets.
+  PUSH_CERTIFICATES: 18,
+
+  // Include change's reviewer updates.
+  REVIEWER_UPDATES: 19,
+
+  // Set the submittable boolean.
+  SUBMITTABLE: 20,
+
+  // If tracking ids are included, include detailed tracking ids info.
+  TRACKING_IDS: 21,
+
+  // Skip mergeability data.
+  SKIP_MERGEABLE: 22,
+
+  // Skip diffstat computation that compute the insertions field (number of lines inserted) and
+  // deletions field (number of lines deleted)
+  SKIP_DIFFSTAT: 23,
+
+  // Include the evaluated submit requirements for the caller.
+  SUBMIT_REQUIREMENTS: 24,
+
+  // Include custom keyed values.
+  CUSTOM_KEYED_VALUES: 25,
+
+  // Include the 'starred' field, that is if the change is starred by the
+  // current user.
+  STAR: 26,
+
+  // Include the `parents_data` field in each revision, e.g. if it's merged in the target branch and
+  // whether it points to a patch-set of another change.
+  PARENTS: 27,
+};
+
 // https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#contributor-agreement-input
 export interface ContributorAgreementInput {
   name?: string;
@@ -690,7 +777,11 @@
   MERGED = 'Merged',
   PRIVATE = 'Private',
   READY_TO_SUBMIT = 'Ready to submit',
+  /** This change is a revert of another change. */
+  REVERT = 'Revert',
+  /** A revert of this change was created. */
   REVERT_CREATED = 'Revert Created',
+  /** A revert of this change was submitted. */
   REVERT_SUBMITTED = 'Revert Submitted',
   WIP = 'WIP',
 }
@@ -836,40 +927,6 @@
   commentThreads: CommentThread[];
 }
 
-/**
- * The CommentInfo entity contains information about an inline comment.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
- */
-export interface CommentInfo {
-  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;
-  line?: number;
-  range?: CommentRange;
-  in_reply_to?: UrlEncodedCommentId;
-  message?: string;
-  author?: AccountInfo;
-  tag?: string;
-  unresolved?: boolean;
-  change_message_id?: string;
-  commit_id?: string;
-  context_lines?: ContextLine[];
-  source_content_type?: string;
-}
-
-/**
- * The ContextLine entity contains the line number and line text of a single line of the source file content..
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#context-line
- */
-export interface ContextLine {
-  line_number: number;
-  context_line: string;
-}
-
 export type NameToProjectInfoMap = {[projectName: string]: ProjectInfo};
 
 export type FilePathToDiffInfoMap = {[path: string]: DiffInfo};
@@ -1112,6 +1169,15 @@
 }
 
 /**
+ * The CustomKeyedValuesInput entity contains information about hashtags to add to, and/or remove from, a change
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#custom-keyed-values-input
+ */
+export interface CustomKeyedValuesInput {
+  add?: CustomKeyedValues;
+  remove?: CustomKey[];
+}
+
+/**
  * The HashtagsInput entity contains information about hashtags to add to, and/or remove from, a change
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#hashtags-input
  */
@@ -1121,15 +1187,6 @@
 }
 
 /**
- * Defines a patch ranges. Used as input for gr-rest-api methods,
- * doesn't exist in Rest API
- */
-export interface PatchRange {
-  patchNum: RevisionPatchSetNum;
-  basePatchNum: BasePatchSetNum;
-}
-
-/**
  * The CommentInput entity contains information for creating an inline comment
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-input
  */
@@ -1237,6 +1294,7 @@
   viewConnections?: boolean;
   viewPlugins?: boolean;
   viewQueue?: boolean;
+  viewSecondaryEmails?: boolean;
 }
 
 /**
@@ -1266,6 +1324,7 @@
   mute_common_path_prefixes?: boolean;
   signed_off_by?: boolean;
   my: TopMenuItemInfo[];
+  // Do not use directly, but use changeTablePrefs() in user model to map/filter legacy columns.
   change_table: string[];
   email_strategy: EmailStrategy;
   default_base_for_merges: DefaultBase;
@@ -1276,6 +1335,7 @@
   // The email_format doesn't mentioned in doc, but exists in Java class GeneralPreferencesInfo
   email_format?: EmailFormat;
   allow_browser_notifications?: boolean;
+  diff_page_sidebar?: DiffPageSidebar;
 }
 
 /**
@@ -1317,6 +1377,7 @@
   add_to_attention_set?: AttentionSetInput[];
   remove_from_attention_set?: AttentionSetInput[];
   ignore_automatic_attention_set_rules?: boolean;
+  response_format_options?: string[];
 }
 
 /**
@@ -1328,6 +1389,7 @@
   labels?: unknown;
   reviewers?: {[key: UserId]: AddReviewerResult};
   ready?: boolean;
+  change_info?: ChangeInfo;
 }
 
 /**
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index 922d779..e59a066 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -37,7 +37,6 @@
     /** Fired when a 'confirm' button in a dialog was pressed. */
     // prettier-ignore
     'confirm': CustomEvent<{}>;
-    'dialog-change': DialogChangeEvent;
     // prettier-ignore
     'drop': DropEvent;
     'hide-alert': CustomEvent<{}>;
@@ -115,14 +114,6 @@
 export type ChangeMessageDeletedEvent =
   CustomEvent<ChangeMessageDeletedEventDetail>;
 
-// TODO(milutin) - remove once new gr-dialog will do it out of the box
-// This informs gr-app-element to remove footer, header from a11y tree
-export interface DialogChangeEventDetail {
-  canceled?: boolean;
-  opened?: boolean;
-}
-export type DialogChangeEvent = CustomEvent<DialogChangeEventDetail>;
-
 export type DropEvent = DragEvent;
 
 export interface EditableContentSaveEventDetail {
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
index 554fa23..4a709b0 100644
--- a/polygerrit-ui/app/types/globals.ts
+++ b/polygerrit-ui/app/types/globals.ts
@@ -36,10 +36,6 @@
     };
 
     /** Enhancements on Gr elements or utils */
-    // TODO(TS): should clean up those and removing them may break certain plugin behaviors
-    // TODO(TS): as @brohlfs suggested, to avoid importing anything from elements/ to types/
-    // use any for them for now
-    GrAnnotation: unknown;
     // Heads up! There is a known plugin dependency on GrPluginActionContext.
     GrPluginActionContext: unknown;
   }
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index 6517836..f48588b 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -12,11 +12,19 @@
   CommitInfo,
   EditPatchSet,
   PatchSetNum,
+  PatchSetNumber,
   ReviewerUpdateInfo,
   RevisionInfo,
+  RevisionPatchSetNum,
   Timestamp,
 } from './common';
 
+// 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;
+}
+
 export function isDefined<T>(x: T): x is NonNullable<T> {
   return x !== undefined && x !== null;
 }
@@ -30,6 +38,12 @@
   GENERIC = 'GENERIC',
 }
 
+export enum LoadingStatus {
+  NOT_LOADED = 'NOT_LOADED',
+  LOADING = 'LOADING',
+  LOADED = 'LOADED',
+}
+
 export interface AuthRequestInit extends RequestInit {
   // RequestInit define headers as HeadersInit, i.e.
   // Headers | string[][] | Record<string, string>
@@ -89,9 +103,15 @@
   return !!(x as PatchSetFile).path;
 }
 
-export interface FileRange {
-  basePath?: string;
-  path: string;
+export function isPatchSetNumber(
+  x?:
+    | PatchSetNum
+    | PatchSetNumber
+    | RevisionPatchSetNum
+    | BasePatchSetNum
+    | null
+): x is PatchSetNumber {
+  return !!x && Number.isInteger(x) && (x as number) > 0;
 }
 
 export interface FetchRequest {
@@ -108,8 +128,12 @@
   updates: {message: string; reviewers: AccountInfo[]}[];
 }
 
+/**
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#edit-info
+ */
 export interface EditRevisionInfo extends Partial<RevisionInfo> {
   // EditRevisionInfo has less required properties then RevisionInfo
+  // TODO: Explicitly list which props are required and optional here.
   _number: EditPatchSet;
   basePatchNum: BasePatchSetNum;
   commit: CommitInfo;
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index 6160cdc..b93acc6 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -16,12 +16,19 @@
   ReviewerInput,
   ServerInfo,
   UserId,
+  Suggestion,
+  isReviewerAccountSuggestion,
+  isReviewerGroupSuggestion,
   SuggestedReviewerAccountInfo,
   SuggestedReviewerGroupInfo,
 } from '../types/common';
 import {AccountTag, ReviewerState} from '../constants/constants';
 import {assertNever, hasOwnProperty} from './common-util';
-import {getDisplayName} from './display-name-util';
+import {
+  getAccountDisplayName,
+  getDisplayName,
+  getGroupDisplayName,
+} from './display-name-util';
 import {getApprovalInfo} from './label-util';
 import {ParsedChangeInfo} from '../types/types';
 
@@ -133,10 +140,10 @@
 
 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;
+  // reviewer entry is returned as just {_account_id: 123}.
+  // At least a name or an email must be set for the account to be treated as
+  // "detailed".
+  return (!!account?.email || !!account?.name) && !!account?._account_id;
 }
 
 /**
@@ -264,3 +271,50 @@
   }
   throw new Error('Must be either an account or a group.');
 }
+
+export function isAccountSuggestion(s: Suggestion): s is AccountInfo {
+  return (s as AccountInfo)._account_id !== undefined;
+}
+
+export function getSuggestedReviewerName(
+  suggestion: Suggestion,
+  config?: ServerInfo
+) {
+  if (isAccountSuggestion(suggestion)) {
+    // Reviewer is an account suggestion from getSuggestedAccounts.
+    return getAccountDisplayName(config, suggestion);
+  }
+
+  if (isReviewerAccountSuggestion(suggestion)) {
+    // Reviewer is an account suggestion from getChangeSuggestedReviewers.
+    return getAccountDisplayName(config, suggestion.account);
+  }
+
+  if (isReviewerGroupSuggestion(suggestion)) {
+    // Reviewer is a group suggestion from getChangeSuggestedReviewers.
+    return getGroupDisplayName(suggestion.group);
+  }
+
+  assertNever(suggestion, 'Received an incorrect suggestion');
+}
+
+export function getSuggestedReviewerID(suggestion: Suggestion) {
+  if (isAccountSuggestion(suggestion)) {
+    // Reviewer is an account suggestion from getSuggestedAccounts.
+    return suggestion.email;
+  }
+
+  if (isReviewerAccountSuggestion(suggestion)) {
+    // Reviewer is an account suggestion from getChangeSuggestedReviewers.
+    return suggestion.account.email;
+  }
+
+  if (isReviewerGroupSuggestion(suggestion)) {
+    // Reviewer is a group suggestion from getChangeSuggestedReviewers.
+    // Groups are special users in Gerrit that do not have an email associated
+    // with them but instead have a groupID.
+    // Adding a group adds all members of that group as reviewer.
+    return suggestion.group.id;
+  }
+  return '';
+}
diff --git a/polygerrit-ui/app/utils/account-util_test.ts b/polygerrit-ui/app/utils/account-util_test.ts
index 72fa791..b1ee50e 100644
--- a/polygerrit-ui/app/utils/account-util_test.ts
+++ b/polygerrit-ui/app/utils/account-util_test.ts
@@ -263,6 +263,7 @@
   test('isDetailedAccount', () => {
     assert.isFalse(isDetailedAccount({_account_id: 12345 as AccountId}));
     assert.isFalse(isDetailedAccount({email: 'abcd' as EmailAddress}));
+    assert.isFalse(isDetailedAccount({name: 'Kermit'}));
 
     assert.isTrue(
       isDetailedAccount({
@@ -270,6 +271,12 @@
         email: 'abcd' as EmailAddress,
       })
     );
+    assert.isTrue(
+      isDetailedAccount({
+        _account_id: 12345 as AccountId,
+        name: 'Kermit',
+      })
+    );
   });
 
   test('fails gracefully when all is not included', async () => {
diff --git a/polygerrit-ui/app/utils/async-util.ts b/polygerrit-ui/app/utils/async-util.ts
index 1af66fa..32a6867 100644
--- a/polygerrit-ui/app/utils/async-util.ts
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -339,32 +339,6 @@
   return wrappedPromise;
 }
 
-export async function waitUntil(
-  predicate: (() => boolean) | (() => Promise<boolean>),
-  message = 'The waitUntil() predicate is still false after 1000 ms.',
-  timeout_ms = 1000
-): Promise<void> {
-  if (await predicate()) return Promise.resolve();
-  const start = Date.now();
-  let sleep = 10;
-  const error = new Error(message);
-  return new Promise((resolve, reject) => {
-    const waiter = async () => {
-      if (await predicate()) {
-        resolve();
-        return;
-      }
-      if (Date.now() - start >= timeout_ms) {
-        reject(error);
-        return;
-      }
-      setTimeout(waiter, sleep);
-      sleep *= 2;
-    };
-    waiter();
-  });
-}
-
 export interface MockPromise<T> extends Promise<T> {
   resolve: (value?: T) => void;
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -397,3 +371,29 @@
     setTimeout(resolve, timeoutMs);
   });
 }
+
+export async function waitUntil(
+  predicate: (() => boolean) | (() => Promise<boolean>),
+  message = 'The waitUntil() predicate is still false after 1000 ms.',
+  timeout_ms = 1000
+): Promise<void> {
+  if (await predicate()) return Promise.resolve();
+  const start = Date.now();
+  let sleep = 10;
+  const error = new Error(message);
+  return new Promise((resolve, reject) => {
+    const waiter = async () => {
+      if (await predicate()) {
+        resolve();
+        return;
+      }
+      if (Date.now() - start >= timeout_ms) {
+        reject(error);
+        return;
+      }
+      setTimeout(waiter, sleep);
+      sleep *= 2;
+    };
+    waiter();
+  });
+}
diff --git a/polygerrit-ui/app/utils/change-metadata-util.ts b/polygerrit-ui/app/utils/change-metadata-util.ts
index 9d3106d..8b208e4 100644
--- a/polygerrit-ui/app/utils/change-metadata-util.ts
+++ b/polygerrit-ui/app/utils/change-metadata-util.ts
@@ -22,6 +22,7 @@
   AUTHOR = 'Author',
   COMMITTER = 'Committer',
   CHERRY_PICK_OF = 'Cherry pick of',
+  REVERT_OF = 'Revert of',
 }
 
 export const DisplayRules = {
@@ -39,6 +40,7 @@
     Metadata.AUTHOR,
     Metadata.COMMITTER,
     Metadata.CHERRY_PICK_OF,
+    Metadata.REVERT_OF,
   ],
   ALWAYS_HIDE: [
     Metadata.PARENT,
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index fcc1361..5079abd 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -16,10 +16,8 @@
 import {ParsedChangeInfo} from '../types/types';
 import {getUserId, isServiceUser} from './account-util';
 
-// This can be wrong! See WARNING above
 interface ChangeStatusesOptions {
-  mergeable: boolean; // This can be wrong! See WARNING above
-  submitEnabled: boolean; // This can be wrong! See WARNING above
+  mergeable: boolean;
   /** Is there a reverting change and if so, what status has it? */
   revertingChangeStatus?: ChangeStatus;
 }
@@ -33,77 +31,6 @@
   REWRITE: 'REWRITE',
 };
 
-// Must be kept in sync with the ListChangesOption enum and protobuf.
-export const ListChangesOption = {
-  LABELS: 0,
-  DETAILED_LABELS: 8,
-
-  // Return information on the current patch set of the change.
-  CURRENT_REVISION: 1,
-  ALL_REVISIONS: 2,
-
-  // If revisions are included, parse the commit object.
-  CURRENT_COMMIT: 3,
-  ALL_COMMITS: 4,
-
-  // If a patch set is included, include the files of the patch set.
-  CURRENT_FILES: 5,
-  ALL_FILES: 6,
-
-  // If accounts are included, include detailed account info.
-  DETAILED_ACCOUNTS: 7,
-
-  // Include messages associated with the change.
-  MESSAGES: 9,
-
-  // Include allowed actions client could perform.
-  CURRENT_ACTIONS: 10,
-
-  // Set the reviewed boolean for the caller.
-  REVIEWED: 11,
-
-  // Include download commands for the caller.
-  DOWNLOAD_COMMANDS: 13,
-
-  // Include patch set weblinks.
-  WEB_LINKS: 14,
-
-  // Include consistency check results.
-  CHECK: 15,
-
-  // Include allowed change actions client could perform.
-  CHANGE_ACTIONS: 16,
-
-  // Include a copy of commit messages including review footers.
-  COMMIT_FOOTERS: 17,
-
-  // Include push certificate information along with any patch sets.
-  PUSH_CERTIFICATES: 18,
-
-  // Include change's reviewer updates.
-  REVIEWER_UPDATES: 19,
-
-  // Set the submittable boolean.
-  SUBMITTABLE: 20,
-
-  // If tracking ids are included, include detailed tracking ids info.
-  TRACKING_IDS: 21,
-
-  // Skip mergeability data.
-  SKIP_MERGEABLE: 22,
-
-  // Skip diffstat computation that compute the insertions field (number of lines inserted) and
-  // deletions field (number of lines deleted)
-  SKIP_DIFFSTAT: 23,
-
-  // Include the evaluated submit requirements for the caller.
-  SUBMIT_REQUIREMENTS: 24,
-
-  // Include the 'starred' field, that is if the change is starred by the
-  // current user.
-  STAR: 25,
-};
-
 export function listChangesOptionsToHex(...args: number[]) {
   let v = 0;
   for (let i = 0; i < args.length; i++) {
@@ -161,6 +88,9 @@
   options?: ChangeStatusesOptions
 ): ChangeStates[] {
   const states = [];
+  if (change.revert_of) {
+    states.push(ChangeStates.REVERT);
+  }
   if (change.status === ChangeStatus.MERGED) {
     if (options?.revertingChangeStatus === ChangeStatus.MERGED) {
       return [ChangeStates.MERGED, ChangeStates.REVERT_SUBMITTED];
@@ -192,11 +122,9 @@
     return states;
   }
 
-  // If no missing requirements, either active or ready to submit.
-  if (change.submittable && options.submitEnabled) {
+  if (change.submittable) {
     states.push(ChangeStates.READY_TO_SUBMIT);
   } else {
-    // Otherwise it is active.
     states.push(ChangeStates.ACTIVE);
   }
   return states;
diff --git a/polygerrit-ui/app/utils/change-util_test.ts b/polygerrit-ui/app/utils/change-util_test.ts
index f768145..6e53c16 100644
--- a/polygerrit-ui/app/utils/change-util_test.ts
+++ b/polygerrit-ui/app/utils/change-util_test.ts
@@ -16,6 +16,7 @@
   AccountId,
   ChangeStates,
   CommitId,
+  ListChangesOption,
   NumericChangeId,
   PatchSetNum,
 } from '../types/common';
@@ -27,7 +28,6 @@
   changePath,
   changeStatuses,
   isRemovableReviewer,
-  ListChangesOption,
   listChangesOptionsToHex,
   hasHumanReviewer,
 } from './change-util';
@@ -66,29 +66,24 @@
     assert.deepEqual(statuses, []);
 
     change.submittable = false;
-    statuses = changeStatuses(change, {mergeable: true, submitEnabled: false});
+    statuses = changeStatuses(change, {mergeable: true});
     assert.deepEqual(statuses, [ChangeStates.ACTIVE]);
 
-    // With no missing labels but no submitEnabled option.
     change.submittable = true;
-    statuses = changeStatuses(change, {mergeable: true, submitEnabled: false});
-    assert.deepEqual(statuses, [ChangeStates.ACTIVE]);
-
-    // Without missing labels and enabled submit
-    statuses = changeStatuses(change, {mergeable: true, submitEnabled: true});
+    statuses = changeStatuses(change, {mergeable: true});
     assert.deepEqual(statuses, [ChangeStates.READY_TO_SUBMIT]);
 
     change.mergeable = false;
     change.submittable = true;
-    statuses = changeStatuses(change, {mergeable: false, submitEnabled: false});
+    statuses = changeStatuses(change, {mergeable: false});
     assert.deepEqual(statuses, [ChangeStates.MERGE_CONFLICT]);
 
     change.mergeable = true;
-    statuses = changeStatuses(change, {mergeable: true, submitEnabled: true});
+    statuses = changeStatuses(change, {mergeable: true});
     assert.deepEqual(statuses, [ChangeStates.READY_TO_SUBMIT]);
 
     change.submittable = true;
-    statuses = changeStatuses(change, {mergeable: false, submitEnabled: false});
+    statuses = changeStatuses(change, {mergeable: false});
     assert.deepEqual(statuses, [ChangeStates.MERGE_CONFLICT]);
   });
 
@@ -141,7 +136,6 @@
       changeStatuses(change, {
         revertingChangeStatus: ChangeStatus.NEW,
         mergeable: true,
-        submitEnabled: true,
       }),
       [ChangeStates.MERGED, ChangeStates.REVERT_CREATED]
     );
@@ -149,7 +143,6 @@
       changeStatuses(change, {
         revertingChangeStatus: ChangeStatus.MERGED,
         mergeable: true,
-        submitEnabled: true,
       }),
       [ChangeStates.MERGED, ChangeStates.REVERT_SUBMITTED]
     );
@@ -170,6 +163,19 @@
     assert.deepEqual(changeStatuses(change), [ChangeStates.ABANDONED]);
   });
 
+  test('Revert status', () => {
+    const change = {
+      ...createChange(),
+      revert_of: 123 as NumericChangeId,
+    };
+    assert.deepEqual(changeStatuses(change), [ChangeStates.REVERT]);
+    change.is_private = true;
+    assert.deepEqual(changeStatuses(change), [
+      ChangeStates.REVERT,
+      ChangeStates.PRIVATE,
+    ]);
+  });
+
   test('Open status with private and wip', () => {
     const change = {
       ...createChange(),
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index ee1a44c..7fad329 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -28,6 +28,7 @@
   SavingState,
   NewDraftInfo,
   isNew,
+  CommentInput,
 } from '../types/common';
 import {CommentSide, SpecialFilePath} from '../constants/constants';
 import {parseDate} from './date-util';
@@ -36,6 +37,7 @@
 import {FormattedReviewerUpdateInfo} from '../types/types';
 import {extractMentionedUsers} from './account-util';
 import {assertIsDefined, uuid} from './common-util';
+import {FILE} from '../api/diff';
 
 export function isFormattedReviewerUpdate(
   message: ChangeMessage
@@ -173,7 +175,7 @@
       rootId: id(comment),
     };
     if (!comment.line && !comment.range) {
-      newThread.line = 'FILE';
+      newThread.line = FILE;
     }
     threads.push(newThread);
     if (id(comment)) idThreadMap[id(comment)] = newThread;
@@ -556,3 +558,36 @@
   }
   return comment;
 }
+
+export function convertToCommentInput(comment: Comment): CommentInput {
+  const output: CommentInput = {
+    message: comment.message,
+    unresolved: comment.unresolved,
+  };
+
+  if (comment.id) {
+    output.id = comment.id;
+  }
+  if (comment.path) {
+    output.path = comment.path;
+  }
+  if (comment.side) {
+    output.side = comment.side;
+  }
+  if (comment.line) {
+    output.line = comment.line;
+  }
+  if (comment.range) {
+    output.range = comment.range;
+  }
+  if (comment.in_reply_to) {
+    output.in_reply_to = comment.in_reply_to;
+  }
+  if (comment.updated) {
+    output.updated = comment.updated;
+  }
+  if (comment.tag) {
+    output.tag = comment.tag;
+  }
+  return output;
+}
diff --git a/polygerrit-ui/app/utils/comment-util_test.ts b/polygerrit-ui/app/utils/comment-util_test.ts
index 7bf0c1e..713e6df 100644
--- a/polygerrit-ui/app/utils/comment-util_test.ts
+++ b/polygerrit-ui/app/utils/comment-util_test.ts
@@ -35,6 +35,7 @@
   UrlEncodedCommentId,
 } from '../types/common';
 import {assert} from '@open-wc/testing';
+import {FILE} from '../api/diff';
 
 suite('comment-util', () => {
   test('isUnresolved', () => {
@@ -213,7 +214,7 @@
       assert.equal(actualThreads[1].comments.length, 1);
       assert.deepEqual(actualThreads[1].comments[0], comments[2]);
       assert.equal(actualThreads[1].patchNum, 1 as RevisionPatchSetNum);
-      assert.equal(actualThreads[1].line, 'FILE');
+      assert.equal(actualThreads[1].line, FILE);
     });
 
     test('derives patchNum and range', () => {
diff --git a/polygerrit-ui/app/utils/dashboard-util.ts b/polygerrit-ui/app/utils/dashboard-util.ts
index caff603..6087e2c 100644
--- a/polygerrit-ui/app/utils/dashboard-util.ts
+++ b/polygerrit-ui/app/utils/dashboard-util.ts
@@ -40,7 +40,7 @@
 
 export const YOUR_TURN: DashboardSection = {
   // Changes where the user is in the attention set.
-  name: 'Your Turn',
+  name: 'Your turn',
   query: 'attention:${user}',
   hideIfEmpty: false,
   suffixForDashboard: 'limit:25',
diff --git a/polygerrit-ui/app/utils/deep-util.ts b/polygerrit-ui/app/utils/deep-util.ts
index 7ed7dd4..eca528e 100644
--- a/polygerrit-ui/app/utils/deep-util.ts
+++ b/polygerrit-ui/app/utils/deep-util.ts
@@ -3,44 +3,76 @@
  * Copyright 2021 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
+
+// NOTE: This algorithm has the following limitations:
+// It does not support deep-value-equality of values in sets that are not
+// `===`.  The same applies for keys in a map.
 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;
+  // The pairs of objects that are currently being compared. If a pair is
+  // encountered again while on the stack, we shouldn't go any deeper, as we
+  // would only be walking through same pairs again infinitely. Such pairs are
+  // equal as long as all non-recursive pairs are equal, ie. given an infinite
+  // traversal we would've never reached a pair of values that are not equal to
+  // each other.
+  const onStackValuePair = new Map<unknown, Set<unknown>>();
+  // Cache of compared object instances. This allows as to avoid comparing same
+  // pair of large objects repeatedly in cases where the reference to the same
+  // object is stored in many different attributes in the tree.
+  const equalValues = new Map<unknown, Set<unknown>>();
+  function deepEqualImpl(a: unknown, b: unknown) {
+    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();
     }
-    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;
+    // Check cache first for container types.
+    if (equalValues?.get(a)?.has(b)) return true;
+
+    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;
+      equalValues.set(a, (equalValues.get(a) ?? new Set()).add(b));
+      return true;
     }
-    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;
+      if (onStackValuePair.get(a)?.has(b)) return true;
+      onStackValuePair.set(a, (onStackValuePair.get(a) ?? new Set()).add(b));
+
+      for (const [aKey, aValue] of a.entries()) {
+        if (!b.has(aKey) || !deepEqualImpl(aValue, b.get(aKey))) return false;
+      }
+      onStackValuePair.get(a)!.delete(b);
+      equalValues.set(a, (equalValues.get(a) ?? new Set()).add(b));
+      return true;
+    }
+
+    if (typeof a === 'object') {
+      if (typeof b !== 'object') return false;
+      if (onStackValuePair.get(a)?.has(b)) return true;
+      onStackValuePair.set(a, (onStackValuePair.get(a) ?? new Set()).add(b));
+
+      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 (!deepEqualImpl(aObj[key], bObj[key])) return false;
+      }
+      onStackValuePair.get(a)!.delete(b);
+      equalValues.set(a, (equalValues.get(a) ?? new Set()).add(b));
+      return true;
+    }
+    return false;
   }
 
-  return false;
+  return deepEqualImpl(a, b);
 }
 
 export function notDeepEqual<T>(a: T, b: T): boolean {
diff --git a/polygerrit-ui/app/utils/deep-util_test.ts b/polygerrit-ui/app/utils/deep-util_test.ts
index c671c53..9e04fa1 100644
--- a/polygerrit-ui/app/utils/deep-util_test.ts
+++ b/polygerrit-ui/app/utils/deep-util_test.ts
@@ -39,6 +39,7 @@
     assert.isFalse(deepEqual({}, null));
     assert.isFalse(deepEqual({}, {x: 'y'}));
     assert.isFalse(deepEqual({x: 'y'}, {x: 'z'}));
+    assert.isFalse(deepEqual({a: 'y'}, {b: 'y'}));
     assert.isFalse(deepEqual({x: 'y'}, {z: 'y'}));
     assert.isFalse(deepEqual({x: {y: 'y'}}, {x: {y: 'z'}}));
   });
@@ -98,4 +99,69 @@
   test('deepEqual nested', () => {
     assert.isFalse(deepEqual({foo: new Set([])}, {foo: new Map([])}));
   });
+
+  test('deepEqual recursive', () => {
+    const a = {};
+    const b = {a};
+    (a as any)['b'] = b;
+    const c = {};
+    const d = {a: c};
+    (c as any)['b'] = d;
+
+    assert.isTrue(deepEqual(a, c));
+  });
+
+  test('deepEqual map recursive', () => {
+    const a = new Map();
+    const b = {a};
+    a.set('b', b);
+
+    const c = new Map();
+    const d = {a: c};
+    c.set('b', d);
+
+    assert.isTrue(deepEqual(a, c));
+  });
+
+  test('deepEqual direct map recursive', () => {
+    const a = new Map();
+    const b = new Map();
+    b.set('a', a);
+    a.set('b', b);
+
+    const c = new Map();
+    const d = new Map();
+    d.set('a', c);
+    c.set('b', d);
+
+    assert.isTrue(deepEqual(a, c));
+  });
+
+  test('deepEqual direct self recursion', () => {
+    const a = {value: 3};
+    (a as any).self = a;
+    const b = {value: 3};
+    (b as any).self = b;
+
+    assert.isTrue(deepEqual(a, b));
+  });
+
+  test('deepEqual through of sets containing Symbols', () => {
+    const asymbol = Symbol('a');
+    const bsymbol = asymbol;
+
+    const a = new Set([asymbol]);
+    const b = new Set([bsymbol]);
+    assert.isTrue(deepEqual(a, b));
+  });
+
+  test('deepEqual recursively deeper', () => {
+    const a: {link?: any} = {};
+    const b: {link?: any} = {};
+    const c: {link?: any} = {};
+    a.link = b;
+    b.link = c;
+    c.link = a;
+    deepEqual(a, c);
+  });
 });
diff --git a/polygerrit-ui/app/utils/diff-util.ts b/polygerrit-ui/app/utils/diff-util.ts
new file mode 100644
index 0000000..e50506a
--- /dev/null
+++ b/polygerrit-ui/app/utils/diff-util.ts
@@ -0,0 +1,97 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+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;
+
+export function otherSide(side: Side) {
+  return side === Side.LEFT ? Side.RIGHT : Side.LEFT;
+}
+
+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);
+}
+
+function getDiffLines(diff: DiffInfo, side: Side): string[] {
+  let lines: string[] = [];
+  for (const chunk of diff.content) {
+    if (chunk.skip) {
+      lines = lines.concat(Array(chunk.skip).fill(''));
+    } else 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);
+    }
+  }
+  return lines;
+}
+
+export function getContentFromDiff(
+  diff: DiffInfo,
+  startLineNum: number,
+  startOffset: number,
+  endLineNum: number | undefined,
+  endOffset: number,
+  side: Side
+) {
+  const lines = getDiffLines(diff, 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');
+}
+
+export function isFileUnchanged(diff: DiffInfo) {
+  return !diff.content.some(
+    content => (content.a && !content.common) || (content.b && !content.common)
+  );
+}
+
+/**
+ * @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);
+  });
+}
+
+/**
+ * Get the approximate length of the diff as the sum of the maximum
+ * length of the chunks.
+ */
+export function getDiffLength(diff?: DiffInfo): number {
+  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);
+}
+
+export function isImageDiff(diff?: DiffInfo) {
+  return (
+    !!diff?.meta_a?.content_type.startsWith('image/') ||
+    !!diff?.meta_b?.content_type.startsWith('image/')
+  );
+}
diff --git a/polygerrit-ui/app/utils/diff-util_test.ts b/polygerrit-ui/app/utils/diff-util_test.ts
new file mode 100644
index 0000000..2829209
--- /dev/null
+++ b/polygerrit-ui/app/utils/diff-util_test.ts
@@ -0,0 +1,118 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {DiffInfo, Side} from '../api/diff';
+import '../test/common-test-setup';
+import {createDiff} from '../test/test-data-generators';
+import {getContentFromDiff, isFileUnchanged} from './diff-util';
+
+suite('diff-util tests', () => {
+  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);
+  });
+
+  suite('getContentFromDiff', () => {
+    test('one changed line', () => {
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [{a: ['abcd']}, {b: ['wxyz']}],
+      };
+      assert.equal(getContentFromDiff(diff, 1, 1, 1, 3, Side.LEFT), 'bc');
+      assert.equal(getContentFromDiff(diff, 1, 1, 1, 3, Side.RIGHT), 'xy');
+    });
+
+    test('one common line', () => {
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [{ab: ['abcd']}],
+      };
+      assert.equal(getContentFromDiff(diff, 1, 1, 1, 3, Side.LEFT), 'bc');
+      assert.equal(getContentFromDiff(diff, 1, 1, 1, 3, Side.RIGHT), 'bc');
+    });
+
+    test('multiple lines', () => {
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [
+          {a: ['l1-asdf', 'l2-asdf']},
+          {b: ['r1-wxyz']},
+          {ab: ['l3-r2-qwer', 'l4-r3-uiop']},
+          {b: ['r4-hjkl']},
+          {ab: ['l5-r5-bnm,']},
+        ],
+      };
+      assert.equal(
+        getContentFromDiff(diff, 1, 0, 5, 10, Side.LEFT),
+        'l1-asdf\nl2-asdf\nl3-r2-qwer\nl4-r3-uiop\nl5-r5-bnm,'
+      );
+      assert.equal(
+        getContentFromDiff(diff, 1, 0, 5, 10, Side.RIGHT),
+        'r1-wxyz\nl3-r2-qwer\nl4-r3-uiop\nr4-hjkl\nl5-r5-bnm,'
+      );
+    });
+
+    test('one skip chunk', () => {
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [{skip: 5}, {ab: ['abcd']}],
+      };
+      assert.equal(getContentFromDiff(diff, 1, 1, 1, 3, Side.LEFT), '');
+      assert.equal(getContentFromDiff(diff, 1, 1, 1, 3, Side.RIGHT), '');
+      assert.equal(getContentFromDiff(diff, 6, 1, 6, 3, Side.LEFT), 'bc');
+      assert.equal(getContentFromDiff(diff, 6, 1, 6, 3, Side.RIGHT), 'bc');
+    });
+
+    test('multiple skip chunks', () => {
+      const diff: DiffInfo = {
+        ...createDiff(),
+        content: [
+          {skip: 5},
+          {ab: ['abcd']},
+          {skip: 5},
+          {ab: ['qwer']},
+          {skip: 5},
+          {ab: ['zxcv']},
+        ],
+      };
+      assert.equal(getContentFromDiff(diff, 1, 1, 1, 3, Side.LEFT), '');
+      assert.equal(getContentFromDiff(diff, 1, 1, 1, 3, Side.RIGHT), '');
+      assert.equal(getContentFromDiff(diff, 6, 1, 6, 3, Side.LEFT), 'bc');
+      assert.equal(getContentFromDiff(diff, 6, 1, 6, 3, Side.RIGHT), 'bc');
+      assert.equal(getContentFromDiff(diff, 12, 1, 12, 3, Side.LEFT), 'we');
+      assert.equal(getContentFromDiff(diff, 12, 1, 12, 3, Side.RIGHT), 'we');
+      assert.equal(getContentFromDiff(diff, 18, 1, 18, 3, Side.LEFT), 'xc');
+      assert.equal(getContentFromDiff(diff, 18, 1, 18, 3, Side.RIGHT), 'xc');
+    });
+  });
+});
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index 056238a..03728a0 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -20,6 +20,10 @@
   return node.nodeType === 1;
 }
 
+export function isHtmlElement(node: Node): node is HTMLElement {
+  return isElement(node) && node instanceof HTMLElement;
+}
+
 export function isElementTarget(
   target: EventTarget | null | undefined
 ): target is Element {
@@ -284,20 +288,6 @@
 }
 
 /**
- * Toggles a CSS class on or off for an element.
- */
-export function toggleClass(el: Element, className: string, bool?: boolean) {
-  if (bool === undefined) {
-    bool = !el.classList.contains(className);
-  }
-  if (bool) {
-    el.classList.add(className);
-  } else {
-    el.classList.remove(className);
-  }
-}
-
-/**
  * For matching the `key` property of KeyboardEvents. These are known to work
  * with Firefox, Safari and Chrome.
  */
diff --git a/polygerrit-ui/app/utils/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
index af545e7..845708b 100644
--- a/polygerrit-ui/app/utils/event-util.ts
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -4,11 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {FetchRequest} from '../types/types';
-import {
-  DialogChangeEventDetail,
-  SwitchTabEventDetail,
-  TabState,
-} from '../types/events';
+import {SwitchTabEventDetail, TabState} from '../types/events';
 
 export type HTMLElementEventDetailType<K extends keyof HTMLElementEventMap> =
   HTMLElementEventMap[K] extends CustomEvent<infer DT> ? DT : never;
@@ -99,15 +95,6 @@
   fire(document, 'title-change', {title});
 }
 
-// TODO(milutin) - remove once new gr-dialog will do it out of the box
-// This informs gr-app-element to remove footer, header from a11y tree
-export function fireDialogChange(
-  target: EventTarget,
-  detail: DialogChangeEventDetail
-) {
-  fire(target, 'dialog-change', detail);
-}
-
 export function fireIronAnnounce(target: EventTarget, text: string) {
   fire(target, 'iron-announce', {text});
 }
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index 8929e9c..27366d9 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -294,7 +294,8 @@
   );
 }
 
-// TODO(milutin): This may be temporary for demo purposes
+// Gerrit is overriding order for standard requirements on change view.
+// Other requirements are ordered as defined in project configuration.
 export const PRIORITY_REQUIREMENTS_ORDER: string[] = [
   StandardLabels.CODE_REVIEW,
   StandardLabels.CODE_OWNERS,
diff --git a/polygerrit-ui/app/utils/link-util.ts b/polygerrit-ui/app/utils/link-util.ts
index 48e9c07..2f12bd7 100644
--- a/polygerrit-ui/app/utils/link-util.ts
+++ b/polygerrit-ui/app/utils/link-util.ts
@@ -135,7 +135,7 @@
 ) {
   return `${
     prefix ?? ''
-  }<a href="${href}" rel="noopener" target="_blank">${displayText}</a>${
+  }<a href="${href}" rel="noopener noreferrer" target="_blank">${displayText}</a>${
     suffix ?? ''
   }`;
 }
diff --git a/polygerrit-ui/app/utils/link-util_test.ts b/polygerrit-ui/app/utils/link-util_test.ts
index e4e719b..1574b3e 100644
--- a/polygerrit-ui/app/utils/link-util_test.ts
+++ b/polygerrit-ui/app/utils/link-util_test.ts
@@ -8,7 +8,7 @@
 
 suite('link-util tests', () => {
   function link(text: string, href: string) {
-    return `<a href="${href}" rel="noopener" target="_blank">${text}</a>`;
+    return `<a href="${href}" rel="noopener noreferrer" target="_blank">${text}</a>`;
   }
 
   suite('link rewrites', () => {
diff --git a/polygerrit-ui/app/utils/location-util.ts b/polygerrit-ui/app/utils/location-util.ts
new file mode 100644
index 0000000..d0eac74
--- /dev/null
+++ b/polygerrit-ui/app/utils/location-util.ts
@@ -0,0 +1,22 @@
+/**
+ * @license
+ * Copyright 2024 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 {safeLocation} from 'safevalues/dom';
+
+export function setHref(loc: Location, url: string) {
+  safeLocation.setHref(loc, url);
+}
+
+export function replace(loc: Location, url: string) {
+  safeLocation.replace(loc, url);
+}
+
+export function assign(loc: Location, url: string) {
+  safeLocation.assign(loc, url);
+}
diff --git a/polygerrit-ui/app/utils/message-util.ts b/polygerrit-ui/app/utils/message-util.ts
index 5acdf33..fffe612 100644
--- a/polygerrit-ui/app/utils/message-util.ts
+++ b/polygerrit-ui/app/utils/message-util.ts
@@ -7,7 +7,8 @@
 import {ChangeId, ChangeMessageInfo} from '../types/common';
 
 function getRevertChangeIdFromMessage(msg: ChangeMessageInfo): ChangeId {
-  const REVERT_REGEX = /^Created a revert of this change as (.*)$/;
+  const REVERT_REGEX =
+    /^Created a revert of this change as .*?(I[0-9a-f]{40})$/;
   const changeId = msg.message.match(REVERT_REGEX)?.[1];
   if (!changeId) throw new Error('revert changeId not found');
   return changeId as ChangeId;
diff --git a/polygerrit-ui/app/utils/message-util_test.ts b/polygerrit-ui/app/utils/message-util_test.ts
index 22a5e4d..64d765a 100644
--- a/polygerrit-ui/app/utils/message-util_test.ts
+++ b/polygerrit-ui/app/utils/message-util_test.ts
@@ -14,12 +14,8 @@
     const messages = [
       {
         ...createChangeMessage(),
-        message: 'Created a revert of this change as 123',
-        tag: MessageTag.TAG_REVERT as ReviewInputTag,
-      },
-      {
-        ...createChangeMessage(),
-        message: 'Created a revert of this change as xyz',
+        message:
+          'Created a revert of this change as If02ca1cd494579d6bb92a157bf1819e3689cd6b1',
         tag: MessageTag.TAG_REVERT as ReviewInputTag,
       },
       {
@@ -30,8 +26,27 @@
     ];
 
     assert.deepEqual(getRevertCreatedChangeIds(messages), [
-      '123' as ChangeId,
-      'xyz' as ChangeId,
+      'If02ca1cd494579d6bb92a157bf1819e3689cd6b1' as ChangeId,
+    ]);
+  });
+
+  test('getRevertCreatedChangeIds with extra spam', () => {
+    const messages = [
+      {
+        ...createChangeMessage(),
+        message:
+          'Created a revert of this change as IIf02ca1cd494579d6bb92a157bf1819e3689cd6b1',
+        tag: MessageTag.TAG_REVERT as ReviewInputTag,
+      },
+      {
+        ...createChangeMessage(),
+        message: 'Created a revert of this change as abc',
+        tag: undefined,
+      },
+    ];
+
+    assert.deepEqual(getRevertCreatedChangeIds(messages), [
+      'If02ca1cd494579d6bb92a157bf1819e3689cd6b1' as ChangeId,
     ]);
   });
 });
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index 7f3b6eb..7a5cd45 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -7,6 +7,8 @@
   PatchSetNumber,
   BasePatchSetNum,
   RevisionPatchSetNum,
+  BranchName,
+  CommitId,
 } from '../types/common';
 import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
 import {assert} from './common-util';
@@ -31,7 +33,7 @@
 export interface PatchSet {
   num: RevisionPatchSetNum;
   desc: string | undefined;
-  sha: string;
+  sha: CommitId;
   wip?: boolean;
 }
 
@@ -197,7 +199,7 @@
       return {
         num: e._number,
         desc: e.description,
-        sha: e.sha,
+        sha: e.sha as CommitId,
       };
     });
   }
@@ -307,9 +309,50 @@
 /**
  * Convert parent indexes from patch range expressions to numbers.
  * For example, in a patch range expression `"-3"` becomes `3`.
- *
  */
-
 export function getParentIndex(rangeBase: PatchSetNum) {
   return -Number(`${rangeBase}`);
 }
+
+export function shorten(sha?: string) {
+  // Using the first 7 characters of the 40 chars commit sha is standard Git convention.
+  return sha?.substring(0, 7);
+}
+
+export function branchName(branch?: string | BranchName): BranchName {
+  if (!branch) return '' as BranchName;
+  if (branch.startsWith('refs/heads/')) {
+    return branch.substring('refs/heads/'.length) as BranchName;
+  }
+  return branch as BranchName;
+}
+
+export function getParentCommit(
+  rev?: RevisionInfo | EditRevisionInfo,
+  index?: number
+) {
+  const parents = rev?.parents_data ?? [];
+  const parent = parents[index ?? 0];
+  if (!parent) return '';
+  return shorten(parent.commit_id) ?? '';
+}
+
+export function getParentInfoString(
+  rev?: RevisionInfo | EditRevisionInfo,
+  index?: number
+) {
+  const parents = rev?.parents_data ?? [];
+  const parent = parents[index ?? 0];
+  if (!parent || parent.is_merged_in_target_branch) return '';
+
+  if (index === 0) {
+    if (parent.change_number) {
+      return `Patchset ${parent.patch_set_number} of Change ${parent.change_number}`;
+    } else {
+      return 'Warning: The base commit is not known (aka reachable) in the target branch.';
+    }
+  } else {
+    // For merge changes the parents with index > 0 are expected to be from a different branch.
+    return 'Other branch';
+  }
+}
diff --git a/polygerrit-ui/app/utils/patch-set-util_test.ts b/polygerrit-ui/app/utils/patch-set-util_test.ts
index b67db9b..31803a4 100644
--- a/polygerrit-ui/app/utils/patch-set-util_test.ts
+++ b/polygerrit-ui/app/utils/patch-set-util_test.ts
@@ -18,6 +18,8 @@
   PatchSetNumber,
   ReviewInputTag,
   PARENT,
+  RevisionInfo,
+  CommitId,
 } from '../types/common';
 import {
   _testOnly_computeWipForPatchSets,
@@ -29,6 +31,7 @@
   isMergeParent,
   sortRevisions,
 } from './patch-set-util';
+import {EditRevisionInfo} from '../types/types';
 
 suite('gr-patch-set-util tests', () => {
   test('getRevisionByPatchNum', () => {
@@ -73,7 +76,7 @@
         }
       }
       const patchSets = Array.from(tagsByRevision.keys()).map(rev => {
-        return {num: rev, desc: 'test', sha: `rev${rev}`};
+        return {num: rev, desc: 'test', sha: `rev${rev}` as CommitId};
       });
       const patchNums = _testOnly_computeWipForPatchSets(change, patchSets);
       const verifier = {
@@ -159,7 +162,11 @@
   });
 
   test('findEditParentRevision', () => {
-    const revisions = [createRevision(0), createRevision(1), createRevision(2)];
+    const revisions: Array<RevisionInfo | EditRevisionInfo> = [
+      createRevision(0),
+      createRevision(1),
+      createRevision(2),
+    ];
     assert.strictEqual(findEditParentRevision(revisions), null);
 
     revisions.push({
@@ -173,7 +180,11 @@
   });
 
   test('findEditParentPatchNum', () => {
-    const revisions = [createRevision(0), createRevision(1), createRevision(2)];
+    const revisions: Array<RevisionInfo | EditRevisionInfo> = [
+      createRevision(0),
+      createRevision(1),
+      createRevision(2),
+    ];
     assert.equal(findEditParentPatchNum(revisions), -1);
 
     revisions.push(
@@ -187,8 +198,16 @@
   });
 
   test('sortRevisions', () => {
-    const revisions = [createRevision(0), createRevision(2), createRevision(1)];
-    const sorted = [createRevision(2), createRevision(1), createRevision(0)];
+    const revisions: Array<RevisionInfo | EditRevisionInfo> = [
+      createRevision(0),
+      createRevision(2),
+      createRevision(1),
+    ];
+    const sorted: Array<RevisionInfo | EditRevisionInfo> = [
+      createRevision(2),
+      createRevision(1),
+      createRevision(0),
+    ];
 
     assert.deepEqual(sortRevisions(revisions), sorted);
 
@@ -203,8 +222,8 @@
     });
     assert.deepEqual(sortRevisions(revisions), sorted);
 
-    revisions[0].basePatchNum = 0 as BasePatchSetNum;
-    const edit = sorted.shift()!;
+    (revisions[0] as EditRevisionInfo).basePatchNum = 0 as BasePatchSetNum;
+    const edit = sorted.shift() as EditRevisionInfo;
     edit.basePatchNum = 0 as BasePatchSetNum;
     // Edit patchset should be at index 2.
     sorted.splice(2, 0, edit);
@@ -217,10 +236,10 @@
 
   test('computeAllPatchSets', () => {
     const expected = [
-      {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'},
+      {num: 4 as PatchSetNumber, desc: 'test', sha: 'rev4' as CommitId},
+      {num: 3 as PatchSetNumber, desc: 'test', sha: 'rev3' as CommitId},
+      {num: 2 as PatchSetNumber, desc: 'test', sha: 'rev2' as CommitId},
+      {num: 1 as PatchSetNumber, desc: 'test', sha: 'rev1' as CommitId},
     ];
     const patchNums = computeAllPatchSets({
       ...createChange(),
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index 2f9bc0c..96edc7e 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -17,6 +17,16 @@
   return self.CANONICAL_PATH || '';
 }
 
+export function getDocUrl(docsBaseUrl: string, relativeUrl: string): string {
+  if (docsBaseUrl.endsWith('/')) {
+    docsBaseUrl = docsBaseUrl.slice(0, -1);
+  }
+  if (relativeUrl.startsWith('/')) {
+    relativeUrl = relativeUrl.slice(1);
+  }
+  return `${docsBaseUrl}/${relativeUrl}`;
+}
+
 /**
  * Return the url to use for login. If the server configuration
  * contains the `loginUrl` in the `auth` section then that custom url
@@ -190,3 +200,9 @@
 export function generateAbsoluteUrl(url: string) {
   return new URL(url, window.location.href).toString();
 }
+
+export function sameOrigin(href: string) {
+  if (!href) return false;
+  const url = new URL(href, window.location.origin);
+  return url.origin === window.location.origin;
+}
diff --git a/polygerrit-ui/app/utils/url-util_test.ts b/polygerrit-ui/app/utils/url-util_test.ts
index e2ca617..a92d8b1 100644
--- a/polygerrit-ui/app/utils/url-util_test.ts
+++ b/polygerrit-ui/app/utils/url-util_test.ts
@@ -15,6 +15,8 @@
   toPath,
   toPathname,
   toSearchParams,
+  sameOrigin,
+  getDocUrl,
 } from './url-util';
 import {assert} from '@open-wc/testing';
 import {createAuth} from '../test/test-data-generators';
@@ -37,6 +39,15 @@
     });
   });
 
+  suite('getDocUrl tests', () => {
+    test('getDocUrl', () => {
+      assert.deepEqual(getDocUrl('a', 'b'), 'a/b');
+      assert.deepEqual(getDocUrl('a/', 'b'), 'a/b');
+      assert.deepEqual(getDocUrl('a', '/b'), 'a/b');
+      assert.deepEqual(getDocUrl('a/', '/b'), 'a/b');
+    });
+  });
+
   suite('loginUrl tests', () => {
     const authConfig = createAuth();
 
@@ -118,6 +129,12 @@
     });
   });
 
+  test('sameOrigin', () => {
+    assert.isTrue(sameOrigin('/asdf'));
+    assert.isTrue(sameOrigin(window.location.origin + '/asdf'));
+    assert.isFalse(sameOrigin('http://www.goole.com/asdf'));
+  });
+
   test('toPathname', () => {
     assert.equal(toPathname('asdf'), 'asdf');
     assert.equal(toPathname('asdf?qwer=zxcv'), 'asdf');
diff --git a/polygerrit-ui/app/workers/service-worker-class.ts b/polygerrit-ui/app/workers/service-worker-class.ts
index 03b6b902..260ee57 100644
--- a/polygerrit-ui/app/workers/service-worker-class.ts
+++ b/polygerrit-ui/app/workers/service-worker-class.ts
@@ -16,7 +16,7 @@
   getServiceWorkerState,
   putServiceWorkerState,
 } from './service-worker-indexdb';
-import {createDashboardUrl} from '../models/views/dashboard';
+import {DashboardType, createDashboardUrl} from '../models/views/dashboard';
 import {createChangeUrl} from '../models/views/change';
 import {noAwait} from '../utils/async-util';
 
@@ -142,7 +142,7 @@
 
   private showNotificationForDashboard(numOfChangesToNotifyAbout: number) {
     const title = `You are in the attention set for ${numOfChangesToNotifyAbout} new changes.`;
-    const dashboardUrl = createDashboardUrl({});
+    const dashboardUrl = createDashboardUrl({type: DashboardType.USER});
     const data = {url: `${self.location.origin}${dashboardUrl}`};
     const icon = `${self.location.origin}/favicon.ico`;
     this.ctx.registration.showNotification(title, {data, icon});
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index e056a35..9cca523 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -2,25 +2,32 @@
 # yarn lockfile v1
 
 
-"@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==
+"@lit-labs/ssr-dom-shim@^1.1.2-pre.0":
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.2.tgz#d693d972974a354034454ec1317eb6afd0b00312"
+  integrity sha512-jnOD+/+dSrfTWYfSXBXlo5l5f0q1UuJo3tkbMDCYA2lKUYq79jaxqtGEvnRoh049nt1vdo1+45RinipU6FGY2g==
+
+"@lit/reactive-element@^2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-2.0.0.tgz#da14a256ac5533873b935840f306d572bac4a2ab"
+  integrity sha512-wn+2+uDcs62ROBmVAwssO4x5xue/uKD3MGGZOXL2sMxReTRIT0JXKyMXeu7gh0aJ4IJNEIG/3aOnUaQvM7BMzQ==
+  dependencies:
+    "@lit-labs/ssr-dom-shim" "^1.1.2-pre.0"
 
 "@mapbox/node-pre-gyp@^1.0.0":
-  version "1.0.5"
-  resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.5.tgz#2a0b32fcb416fb3f2250fd24cb2a81421a4f5950"
-  integrity sha512-4srsKPXWlIxp5Vbqz5uLfBN+du2fJChBoYn/f2h991WLdk7jUvcSk/McVLSv/X+xQIPI8eGD5GjrnygdyHnhPA==
+  version "1.0.11"
+  resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa"
+  integrity sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==
   dependencies:
-    detect-libc "^1.0.3"
+    detect-libc "^2.0.0"
     https-proxy-agent "^5.0.0"
     make-dir "^3.1.0"
-    node-fetch "^2.6.1"
+    node-fetch "^2.6.7"
     nopt "^5.0.0"
-    npmlog "^4.1.2"
+    npmlog "^5.0.1"
     rimraf "^3.0.2"
-    semver "^7.3.4"
-    tar "^6.1.0"
+    semver "^7.3.5"
+    tar "^6.1.11"
 
 "@polymer/decorators@^3.0.0":
   version "3.0.0"
@@ -39,7 +46,7 @@
   resolved "https://registry.yarnpkg.com/@polymer/font-roboto/-/font-roboto-3.0.2.tgz#80cdaa7225db2359130dfb2c6d9a3be1820020c3"
   integrity sha512-tx5TauYSmzsIvmSqepUPDYbs4/Ejz2XbZ1IkD7JEGqkdNUJlh+9KU85G56Tfdk/xjEZ8zorFfN09OSwiMrIQWA==
 
-"@polymer/iron-a11y-announcer@^3.0.0-pre.26", "@polymer/iron-a11y-announcer@^3.1.0":
+"@polymer/iron-a11y-announcer@^3.0.0-pre.26", "@polymer/iron-a11y-announcer@^3.2.0":
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/@polymer/iron-a11y-announcer/-/iron-a11y-announcer-3.2.0.tgz#d04b1301413d473336cc5797dfd97b3b36dd0cd7"
   integrity sha512-We+hyaFHcg7Ke8ovsoxUpYEXFIJLHxMCDaLehTB4dELS+C+K0zMnGSiqQvb/YzGS+nSYpAfkQIyg1msOCdHMtA==
@@ -416,32 +423,32 @@
     "@polymer/paper-styles" "^3.0.0-pre.26"
     "@polymer/polymer" "^3.0.0"
 
-"@polymer/polymer@^3.0.0", "@polymer/polymer@^3.0.2", "@polymer/polymer@^3.0.5", "@polymer/polymer@^3.3.1", "@polymer/polymer@^3.4.1":
-  version "3.4.1"
-  resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.4.1.tgz#333bef25711f8411bb5624fb3eba8212ef8bee96"
-  integrity sha512-KPWnhDZibtqKrUz7enIPOiO4ZQoJNOuLwqrhV2MXzIt3VVnUVJVG5ORz4Z2sgO+UZ+/UZnPD0jqY+jmw/+a9mQ==
+"@polymer/polymer@^3.0.0", "@polymer/polymer@^3.0.2", "@polymer/polymer@^3.0.5", "@polymer/polymer@^3.3.1", "@polymer/polymer@^3.5.1":
+  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"
 
-"@types/resemblejs@^3.2.0":
-  version "3.2.1"
-  resolved "https://registry.yarnpkg.com/@types/resemblejs/-/resemblejs-3.2.1.tgz#f4d6dc1549184f6a8ac71f831547f055421ba926"
-  integrity sha512-PEAcjrHtLYqxhjkRrHVCyyQBk1A58aVlDkpmFpcSIejE+PNz9ovEJKSH8iyNOOBoDPNA1JBvaaBUYtFgEbFjiw==
+"@types/resemblejs@^4.1.0":
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/@types/resemblejs/-/resemblejs-4.1.0.tgz#1c150e0de4117b29f9d5d5231489edc7cef8263e"
+  integrity sha512-+MIkKy/UngDfhTnvn2yK/KSzlbtLeB5BU73qqZrzIF24+e2r8enJ4cW3UbtkstByYSDV8pbheGAqg7zT8ZZ2pA==
 
-"@types/resize-observer-browser@^0.1.5":
-  version "0.1.6"
-  resolved "https://registry.yarnpkg.com/@types/resize-observer-browser/-/resize-observer-browser-0.1.6.tgz#d8e6c2f830e2650dc06fe74464472ff64b54a302"
-  integrity sha512-61IfTac0s9jvNtBCpyo86QeaN8qqpMGHdK0uGKCCIy2dt5/Yk84VduHIdWAcmkC5QvdkPL0p5eWYgUZtHKKUVg==
+"@types/resize-observer-browser@^0.1.7":
+  version "0.1.8"
+  resolved "https://registry.yarnpkg.com/@types/resize-observer-browser/-/resize-observer-browser-0.1.8.tgz#db41a93f9f37dad8e6f0c7bd2f5bbc042bf714d1"
+  integrity sha512-OpjAd26fD1G2OWlYzkrapJ12n+kyi0znYgE2AHfNccHY/am3kG+lfJ5brfcZ7+1CIybkPWGKrW+Wm97kbcOQaQ==
 
 "@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==
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.4.tgz#2b38784cd16957d3782e8e2b31c03bc1d13b4d65"
+  integrity sha512-IDaobHimLQhjwsQ/NMwRVfa/yL7L/wriQPMhw1ZJall0KX6E1oxk29XMDeilW5qTIg5aoiqf5Udy8U/51aNoQQ==
 
-"@webcomponents/shadycss@^1.10.2", "@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==
+"@webcomponents/shadycss@^1.11.2", "@webcomponents/shadycss@^1.9.1":
+  version "1.11.2"
+  resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.11.2.tgz#7539b0ad29598aa2eafee8b341059e20ac9e1006"
+  integrity sha512-vRq+GniJAYSBmTRnhCYPAPq6THYqovJ/gzGThWbgEZUQaBccndGTi1hdiUP15HzEco0I6t4RCtXyX0rsSmwgPw==
 
 "@webcomponents/webcomponentsjs@^1.3.3":
   version "1.3.3"
@@ -449,9 +456,9 @@
   integrity sha512-eLH04VBMpuZGzBIhOnUjECcQPEPcmfhWEijW9u1B5I+2PPYdWf3vWUExdDxu4Y3GljRSTCOlWnGtS9tpzmXMyQ==
 
 "@webcomponents/webcomponentsjs@^2.0.3":
-  version "2.6.0"
-  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.6.0.tgz#7d1674c40bddf0c6dd974c44ffd34512fe7274ff"
-  integrity sha512-Moog+Smx3ORTbWwuPqoclr+uvfLnciVd6wdCaVscHPrxbmQ/IJKm3wbB7hpzJtXWjAq2l/6QMlO85aZiOdtv5Q==
+  version "2.8.0"
+  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.8.0.tgz#ab21f027594fa827c1889e8b646da7be27c7908a"
+  integrity sha512-loGD63sacRzOzSJgQnB9ZAhaQGkN7wl2Zuw7tsphI5Isa0irijrRo6EnJii/GgjGefIFO8AIO7UivzRhFaEk9w==
 
 abbrev@1:
   version "1.1.1"
@@ -465,28 +472,23 @@
   dependencies:
     debug "4"
 
-ansi-regex@^2.0.0:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
-  integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8=
+ansi-regex@^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-regex@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
-  integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=
+"aproba@^1.0.3 || ^2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc"
+  integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==
 
-aproba@^1.0.3:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
-  integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==
-
-are-we-there-yet@~1.1.2:
-  version "1.1.7"
-  resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz#b15474a932adab4ff8a50d9adfa7e4e926f21146"
-  integrity sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==
+are-we-there-yet@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c"
+  integrity sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==
   dependencies:
     delegates "^1.0.0"
-    readable-stream "^2.0.6"
+    readable-stream "^3.6.0"
 
 balanced-match@^1.0.0:
   version "1.0.2"
@@ -501,13 +503,13 @@
     balanced-match "^1.0.0"
     concat-map "0.0.1"
 
-canvas@2.8.0:
-  version "2.8.0"
-  resolved "https://registry.yarnpkg.com/canvas/-/canvas-2.8.0.tgz#f99ca7f25e6e26686661ffa4fec1239bbef74461"
-  integrity sha512-gLTi17X8WY9Cf5GZ2Yns8T5lfBOcGgFehDFb+JQwDqdOoBOcECS9ZWMEAqMSVcMYwXD659J8NyzjRY/2aE+C2Q==
+canvas@2.11.2:
+  version "2.11.2"
+  resolved "https://registry.yarnpkg.com/canvas/-/canvas-2.11.2.tgz#553d87b1e0228c7ac0fc72887c3adbac4abbd860"
+  integrity sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==
   dependencies:
     "@mapbox/node-pre-gyp" "^1.0.0"
-    nan "^2.14.0"
+    nan "^2.17.0"
     simple-get "^3.0.3"
 
 chownr@^2.0.0:
@@ -515,30 +517,25 @@
   resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece"
   integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==
 
-code-point-at@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
-  integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
+color-support@^1.1.2:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2"
+  integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==
 
 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=
+  integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
 
-console-control-strings@^1.0.0, console-control-strings@~1.1.0:
+console-control-strings@^1.0.0, console-control-strings@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
-  integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=
-
-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==
+  integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==
 
 debug@4:
-  version "4.3.2"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b"
-  integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==
+  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"
 
@@ -552,12 +549,17 @@
 delegates@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
-  integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
+  integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==
 
-detect-libc@^1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
-  integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=
+detect-libc@^2.0.0:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.2.tgz#8ccf2ba9315350e1241b88d0ac3b0e1fbd99605d"
+  integrity sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==
+
+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==
 
 fs-minipass@^2.0.0:
   version "2.1.0"
@@ -569,132 +571,117 @@
 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=
+  integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
 
-gauge@~2.7.3:
-  version "2.7.4"
-  resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
-  integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=
+gauge@^3.0.0:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395"
+  integrity sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==
   dependencies:
-    aproba "^1.0.3"
+    aproba "^1.0.3 || ^2.0.0"
+    color-support "^1.1.2"
     console-control-strings "^1.0.0"
-    has-unicode "^2.0.0"
-    object-assign "^4.1.0"
+    has-unicode "^2.0.1"
+    object-assign "^4.1.1"
     signal-exit "^3.0.0"
-    string-width "^1.0.1"
-    strip-ansi "^3.0.1"
-    wide-align "^1.1.0"
+    string-width "^4.2.3"
+    strip-ansi "^6.0.1"
+    wide-align "^1.1.2"
 
 glob@^7.1.3:
-  version "7.2.0"
-  resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023"
-  integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==
+  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"
 
-has-unicode@^2.0.0:
+has-unicode@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9"
-  integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=
+  integrity sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==
 
 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==
+"highlight.js@^11.5.0 || ^10.4.1", highlight.js@^11.8.0:
+  version "11.8.0"
+  resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.8.0.tgz#966518ea83257bae2e7c9a48596231856555bb65"
+  integrity sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==
 
 "highlightjs-closure-templates@https://github.com/highlightjs/highlightjs-closure-templates":
   version "0.0.1"
-  resolved "https://github.com/highlightjs/highlightjs-closure-templates#2ac39a4a0ddf08dc826e341ababf3d00fd69878a"
+  resolved "https://github.com/highlightjs/highlightjs-closure-templates#7922b1e68def8b10199e186bb679600de3ebb711"
   dependencies:
-    highlight.js "^11.3.1 || ^10.4.1"
+    highlight.js "^11.5.0 || ^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"
+  version "1.4.9"
+  resolved "https://github.com/highlightjs/highlightjs-structured-text#e68dd7aa829529fb6c40d6287585f43273605a9e"
   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"
-  integrity sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==
+  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"
 
-immer@^9.0.5:
-  version "9.0.6"
-  resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.6.tgz#7a96bf2674d06c8143e327cbf73539388ddf1a73"
-  integrity sha512-G95ivKpy+EvVAnAab4fVa4YGYn24J1SpEktnJX7JJ45Bd7xqME/SCplFzYFmTbrkwZbQ4xJK1xMTUYBkN6pWsQ==
+immer@^9.0.21:
+  version "9.0.21"
+  resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.21.tgz#1e025ea31a40f24fb064f1fef23e931496330176"
+  integrity sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==
 
 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.3:
+inherits@2, inherits@^2.0.3:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
 
-is-fullwidth-code-point@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb"
-  integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs=
+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==
+
+lit-element@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-4.0.0.tgz#8343891bc9159a5fcb7f534914b37f2c0161e036"
+  integrity sha512-N6+f7XgusURHl69DUZU6sTBGlIN+9Ixfs3ykkNDfgfTkDYGGOWwHAYBhDqVswnFGyWgQYR2KiSpu4J76Kccs/A==
   dependencies:
-    number-is-nan "^1.0.0"
+    "@lit-labs/ssr-dom-shim" "^1.1.2-pre.0"
+    "@lit/reactive-element" "^2.0.0"
+    lit-html "^3.0.0"
 
-is-fullwidth-code-point@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
-  integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=
-
-isarray@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
-  integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
-
-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.3.0"
-    lit-html "^2.2.0"
-
-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==
+lit-html@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-3.0.0.tgz#77d6776ee488642c74c5575315ef81aa09d24ea9"
+  integrity sha512-DNJIE8dNY0dQF2Gs0sdMNUppMQT2/CvV4OVnSdg7BXAsGqkVwsE5bqQ04POfkYH5dBIuGnJYdFz5fYYyNnOxiA==
   dependencies:
     "@types/trusted-types" "^2.0.2"
 
-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==
+lit@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-3.0.0.tgz#204bd65935892a73670471e893ee8ca55d2f9a3b"
+  integrity sha512-nQ0teRzU1Kdj++VdmttS2WvIen8M79wChJ6guRKIIym2M3Ansg3Adj9O6yuQh2IpjxiUXlNuS81WKlQ4iL3BmA==
   dependencies:
-    "@lit/reactive-element" "^1.3.0"
-    lit-element "^3.2.0"
-    lit-html "^2.2.0"
+    "@lit/reactive-element" "^2.0.0"
+    lit-element "^4.0.0"
+    lit-html "^3.0.0"
 
 lru-cache@^6.0.0:
   version "6.0.0"
@@ -720,20 +707,25 @@
   resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43"
   integrity sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==
 
-minimatch@^3.0.4:
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
-  integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
+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"
 
 minipass@^3.0.0:
-  version "3.1.5"
-  resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.5.tgz#71f6251b0a33a49c01b3cf97ff77eda030dff732"
-  integrity sha512-+8NzxD82XQoNKNrl1d/FSi+X8wAEWR+sbYAfIvub4Nz0d22plFG72CEVVaufV8PNf4qSslFTD8VMOxNVhHCjTw==
+  version "3.3.6"
+  resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a"
+  integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==
   dependencies:
     yallist "^4.0.0"
 
+minipass@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d"
+  integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==
+
 minizlib@^2.1.1:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931"
@@ -752,15 +744,15 @@
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
   integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
 
-nan@^2.14.0:
-  version "2.15.0"
-  resolved "https://registry.yarnpkg.com/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee"
-  integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==
+nan@^2.17.0:
+  version "2.18.0"
+  resolved "https://registry.yarnpkg.com/nan/-/nan-2.18.0.tgz#26a6faae7ffbeb293a39660e88a76b82e30b7554"
+  integrity sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w==
 
-node-fetch@^2.6.1:
-  version "2.6.5"
-  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.5.tgz#42735537d7f080a7e5f78b6c549b7146be1742fd"
-  integrity sha512-mmlIVHJEu5rnIxgEgez6b9GgWXbkZj5YZ7fx+2r94a2E+Uirsp6HsPTPlomfdHtpt/B0cdKviwkoaM6pyvUOpQ==
+node-fetch@^2.6.7:
+  version "2.7.0"
+  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
+  integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
   dependencies:
     whatwg-url "^5.0.0"
 
@@ -771,37 +763,32 @@
   dependencies:
     abbrev "1"
 
-npmlog@^4.1.2:
-  version "4.1.2"
-  resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b"
-  integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==
+npmlog@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-5.0.1.tgz#f06678e80e29419ad67ab964e0fa69959c1eb8b0"
+  integrity sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==
   dependencies:
-    are-we-there-yet "~1.1.2"
-    console-control-strings "~1.1.0"
-    gauge "~2.7.3"
-    set-blocking "~2.0.0"
+    are-we-there-yet "^2.0.0"
+    console-control-strings "^1.1.0"
+    gauge "^3.0.0"
+    set-blocking "^2.0.0"
 
-number-is-nan@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
-  integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=
-
-object-assign@^4.1.0:
+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=
+  integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
 
 once@^1.3.0, once@^1.3.1:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
-  integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
+  integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==
   dependencies:
     wrappy "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=
+  integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==
 
 "polymer-bridges@file:../../polymer-bridges":
   version "1.0.0"
@@ -814,30 +801,21 @@
     "@polymer/polymer" "^3.0.2"
     "@webcomponents/webcomponentsjs" "^2.0.3"
 
-process-nextick-args@~2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
-  integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
-
-readable-stream@^2.0.6:
-  version "2.3.7"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
-  integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
+readable-stream@^3.6.0:
+  version "3.6.2"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967"
+  integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==
   dependencies:
-    core-util-is "~1.0.0"
-    inherits "~2.0.3"
-    isarray "~1.0.0"
-    process-nextick-args "~2.0.0"
-    safe-buffer "~5.1.1"
-    string_decoder "~1.1.1"
-    util-deprecate "~1.0.1"
+    inherits "^2.0.3"
+    string_decoder "^1.1.1"
+    util-deprecate "^1.0.1"
 
-resemblejs@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/resemblejs/-/resemblejs-4.0.0.tgz#5382f0484430d826ed293433833b9fc4e06e5496"
-  integrity sha512-vaGs/hFVx/941+RS4UJtd8DQvx5RuB61tPLOQCxPso3JpmjfDb6odH5HViT17S0d8DaZsexD01nRJI12giCz/A==
+resemblejs@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/resemblejs/-/resemblejs-5.0.0.tgz#f5a0c6aaa59dcfb9f5192e7ab8740616cbbbf220"
+  integrity sha512-+B0eP9k9VDP/YhBbH+ZdYmHiotdtuc6blVI+h8wwkY2cOow+uiIpSmgkBBBtrEAL0D31/gR/AJPwDeX5TcwmIA==
   optionalDependencies:
-    canvas "2.8.0"
+    canvas "2.11.2"
 
 rimraf@^3.0.2:
   version "3.0.2"
@@ -853,37 +831,37 @@
   dependencies:
     tslib "^1.9.0"
 
-safe-buffer@~5.1.0, safe-buffer@~5.1.1:
-  version "5.1.2"
-  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
-  integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
+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==
 
-safevalues@^0.3.1:
+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"
-  integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
+  version "6.3.1"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
+  integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
 
-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==
+semver@^7.3.5:
+  version "7.5.4"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
+  integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
   dependencies:
     lru-cache "^6.0.0"
 
-set-blocking@~2.0.0:
+set-blocking@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
-  integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
+  integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==
 
 signal-exit@^3.0.0:
-  version "3.0.5"
-  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.5.tgz#9e3e8cc0c75a99472b44321033a7702e7738252f"
-  integrity sha512-KWcOiKeQj6ZyXx7zq4YxSMgHRlod4czeBQZrPb8OKcohcqAXShm7E20kEMle9WBt26hFcAf0qLOcp5zmY7kOqQ==
+  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==
 
 simple-concat@^1.0.0:
   version "1.0.1"
@@ -891,60 +869,45 @@
   integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==
 
 simple-get@^3.0.3:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-3.1.0.tgz#b45be062435e50d159540b576202ceec40b9c6b3"
-  integrity sha512-bCR6cP+aTdScaQCnQKbPKtJOKDp/hj9EDLJo3Nw4y1QksqaovlW/bnptB6/c1e+qmNIDHRK+oXFDdEqBT8WzUA==
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-3.1.1.tgz#cc7ba77cfbe761036fbfce3d021af25fc5584d55"
+  integrity sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==
   dependencies:
     decompress-response "^4.2.0"
     once "^1.3.1"
     simple-concat "^1.0.0"
 
-string-width@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"
-  integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=
+"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.2.3:
+  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:
-    code-point-at "^1.0.0"
-    is-fullwidth-code-point "^1.0.0"
-    strip-ansi "^3.0.0"
+    emoji-regex "^8.0.0"
+    is-fullwidth-code-point "^3.0.0"
+    strip-ansi "^6.0.1"
 
-"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==
+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:
-    is-fullwidth-code-point "^2.0.0"
-    strip-ansi "^4.0.0"
+    safe-buffer "~5.2.0"
 
-string_decoder@~1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
-  integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
+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:
-    safe-buffer "~5.1.0"
+    ansi-regex "^5.0.1"
 
-strip-ansi@^3.0.0, strip-ansi@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf"
-  integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=
-  dependencies:
-    ansi-regex "^2.0.0"
-
-strip-ansi@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f"
-  integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8=
-  dependencies:
-    ansi-regex "^3.0.0"
-
-tar@^6.1.0:
-  version "6.1.11"
-  resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.11.tgz#6760a38f003afa1b2ffd0ffe9e9abbd0eab3d621"
-  integrity sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==
+tar@^6.1.11:
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.0.tgz#b14ce49a79cb1cd23bc9b016302dea5474493f73"
+  integrity sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==
   dependencies:
     chownr "^2.0.0"
     fs-minipass "^2.0.0"
-    minipass "^3.0.0"
+    minipass "^5.0.0"
     minizlib "^2.1.1"
     mkdirp "^1.0.3"
     yallist "^4.0.0"
@@ -952,47 +915,47 @@
 tr46@~0.0.3:
   version "0.0.3"
   resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
-  integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=
+  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==
 
-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=
+  integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
 
-web-vitals@^3.0.0:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-3.1.1.tgz#bb124a03df7a135617f495c5bb7dbc30ecf2cce3"
-  integrity sha512-qvllU+ZeQChqzBhZ1oyXmWsjJ8a2jHYpH8AMaVuf29yscOPZfTQTjQFRX6+eADTdsDE8IanOZ0cetweHMs8/2A==
+web-vitals@^3.4.0:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-3.4.0.tgz#45ed33a3a2e029dc38d36547eb5d71d1c7e2552d"
+  integrity sha512-n9fZ5/bG1oeDkyxLWyep0eahrNcPDF6bFqoyispt7xkW0xhDzpUBTgyDKqWDi1twT0MgH4HvvqzpUyh0ZxZV4A==
 
 webidl-conversions@^3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
-  integrity sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=
+  integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==
 
 whatwg-url@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
-  integrity sha1-lmRU6HZUYuN2RNNib2dCzotwll0=
+  integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==
   dependencies:
     tr46 "~0.0.3"
     webidl-conversions "^3.0.0"
 
-wide-align@^1.1.0:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457"
-  integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==
+wide-align@^1.1.2:
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3"
+  integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==
   dependencies:
-    string-width "^1.0.2 || 2"
+    string-width "^1.0.2 || 2 || 3 || 4"
 
 wrappy@1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
-  integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
+  integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
 
 yallist@^4.0.0:
   version "4.0.0"
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index 6fa4d0f..0465f05 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -3,24 +3,24 @@
   "description": "Gerrit Code Review - Polygerrit dev dependencies",
   "browser": true,
   "dependencies": {
-    "@types/sinon": "^10.0.0"
+    "@types/sinon": "^10.0.16"
   },
   "devDependencies": {
     "@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",
+    "@open-wc/semantic-dom-diff": "^0.19.9",
+    "@open-wc/testing": "^3.2.0",
+    "@web/dev-server-esbuild": "^0.3.6",
     "@web/test-runner": "^0.14.0",
     "@web/test-runner-playwright": "^0.9.0",
-    "@web/test-runner-visual-regression": "^0.6.6",
+    "@web/test-runner-visual-regression": "^0.7.1",
     "accessibility-developer-tools": "^2.12.0",
-    "karma": "^6.3.20",
-    "karma-chrome-launcher": "^3.1.1",
+    "karma": "^6.4.2",
+    "karma-chrome-launcher": "^3.2.0",
     "karma-mocha": "^2.0.1",
     "karma-mocha-reporter": "^2.2.5",
     "mocha": "9.2.2",
-    "sinon": "^13.0.0",
-    "source-map-support": "^0.5.19"
+    "sinon": "^13.0.2",
+    "source-map-support": "^0.5.21"
   },
   "scripts": {
     "test": "web-test-runner",
@@ -29,7 +29,7 @@
     "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": "web-test-runner --watch --group default --files",
     "test:single:coverage": "web-test-runner --watch --coverage --files"
   },
   "license": "Apache-2.0",
diff --git a/polygerrit-ui/web-test-runner.config.mjs b/polygerrit-ui/web-test-runner.config.mjs
index 552e609..eb377d2 100644
--- a/polygerrit-ui/web-test-runner.config.mjs
+++ b/polygerrit-ui/web-test-runner.config.mjs
@@ -2,8 +2,29 @@
 import { defaultReporter, summaryReporter } from "@web/test-runner";
 import { visualRegressionPlugin } from "@web/test-runner-visual-regression/plugin";
 
+function testRunnerHtmlFactory() {
+  return (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>
+  `;
+}
+
 /** @type {import('@web/test-runner').TestRunnerConfig} */
 const config = {
+  // TODO: https://g-issues.gerritcodereview.com/issues/365565157 - undo the
+  // change once the underlying issue is fixed.
+  concurrency: 1,
   files: [
     "app/**/*_test.{ts,js}",
     "!**/node_modules/**/*",
@@ -42,20 +63,6 @@
       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>
-  `,
+  testRunnerHtml: testRunnerHtmlFactory(),
 };
 export default config;
diff --git a/polygerrit-ui/yarn.lock b/polygerrit-ui/yarn.lock
index 35409a8..6e8259c 100644
--- a/polygerrit-ui/yarn.lock
+++ b/polygerrit-ui/yarn.lock
@@ -2,336 +2,286 @@
 # yarn lockfile v1
 
 
-"@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==
+"@75lb/deep-merge@^1.1.1":
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/@75lb/deep-merge/-/deep-merge-1.1.1.tgz#3b06155b90d34f5f8cc2107d796f1853ba02fd6d"
+  integrity sha512-xvgv6pkMGBA6GwdyJbNAnDmfAIR/DfWhrj9jgWh3TY7gRm3KO46x/GPjRg6wJ0nOepwqrNxFfojebh0Df4h4Tw==
   dependencies:
-    "@jridgewell/gen-mapping" "^0.1.0"
+    lodash.assignwith "^4.2.0"
+    typical "^7.1.1"
+
+"@ampproject/remapping@^2.2.0":
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.1.tgz#99e8e11851128b8702cd57c33684f1d0f260b630"
+  integrity sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==
+  dependencies:
+    "@jridgewell/gen-mapping" "^0.3.0"
     "@jridgewell/trace-mapping" "^0.3.9"
 
-"@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==
+"@babel/code-frame@^7.12.11", "@babel/code-frame@^7.22.13":
+  version "7.22.13"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.13.tgz#e3c1c099402598483b7a8c46a721d1038803755e"
+  integrity sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==
   dependencies:
-    "@babel/highlight" "^7.16.0"
+    "@babel/highlight" "^7.22.13"
+    chalk "^2.4.2"
 
-"@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/compat-data@^7.22.20", "@babel/compat-data@^7.22.6", "@babel/compat-data@^7.22.9":
+  version "7.22.20"
+  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.22.20.tgz#8df6e96661209623f1975d66c35ffca66f3306d0"
+  integrity sha512-BQYjKbpXjoXwFW5jGqiizJQQT/aC7pFm9Ok1OWssonuguICi264lbgMzRp2ZMmRSlfkX6DsWDDcsrctK8Rwfiw==
 
 "@babel/core@^7.11.1":
-  version "7.19.0"
-  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.19.0.tgz#d2f5f4f2033c00de8096be3c9f45772563e150c3"
-  integrity sha512-reM4+U7B9ss148rh2n1Qs9ASS+w94irYXga7c2jaQv9RVzpS7Mv1a9rnYYwuDa45G+DkORt9g6An2k/V4d9LbQ==
+  version "7.22.20"
+  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.22.20.tgz#e3d0eed84c049e2a2ae0a64d27b6a37edec385b7"
+  integrity sha512-Y6jd1ahLubuYweD/zJH+vvOY141v4f9igNQAQ+MBgq9JlHS2iTsZKn1aMsb3vGccZsXI16VzTBw52Xx0DWmtnA==
   dependencies:
-    "@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"
+    "@ampproject/remapping" "^2.2.0"
+    "@babel/code-frame" "^7.22.13"
+    "@babel/generator" "^7.22.15"
+    "@babel/helper-compilation-targets" "^7.22.15"
+    "@babel/helper-module-transforms" "^7.22.20"
+    "@babel/helpers" "^7.22.15"
+    "@babel/parser" "^7.22.16"
+    "@babel/template" "^7.22.15"
+    "@babel/traverse" "^7.22.20"
+    "@babel/types" "^7.22.19"
     convert-source-map "^1.7.0"
     debug "^4.1.0"
     gensync "^1.0.0-beta.2"
-    json5 "^2.2.1"
-    semver "^6.3.0"
+    json5 "^2.2.3"
+    semver "^6.3.1"
 
-"@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==
+"@babel/generator@^7.22.15", "@babel/generator@^7.4.0":
+  version "7.22.15"
+  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.22.15.tgz#1564189c7ec94cb8f77b5e8a90c4d200d21b2339"
+  integrity sha512-Zu9oWARBqeVOW0dZOjXc3JObrzuqothQ3y/n1kUtrjCoCPLkXUwMvOo/F/TCfoHMbWIFlWwpZtkZVb9ga4U2pA==
   dependencies:
-    "@babel/types" "^7.19.0"
+    "@babel/types" "^7.22.15"
     "@jridgewell/gen-mapping" "^0.3.2"
+    "@jridgewell/trace-mapping" "^0.3.17"
     jsesc "^2.5.1"
 
-"@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==
+"@babel/helper-annotate-as-pure@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz#e7f06737b197d580a01edf75d97e2c8be99d3882"
+  integrity sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==
   dependencies:
-    "@babel/types" "^7.18.6"
+    "@babel/types" "^7.22.5"
 
-"@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==
+"@babel/helper-builder-binary-assignment-operator-visitor@^7.22.5":
+  version "7.22.15"
+  resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.15.tgz#5426b109cf3ad47b91120f8328d8ab1be8b0b956"
+  integrity sha512-QkBXwGgaoC2GtGZRoma6kv7Szfv06khvhFav67ZExau2RaXzy8MpHSMO2PNoP2XtmQphJQRHFfg77Bq731Yizw==
   dependencies:
-    "@babel/helper-explode-assignable-expression" "^7.18.6"
-    "@babel/types" "^7.18.9"
+    "@babel/types" "^7.22.15"
 
-"@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==
+"@babel/helper-compilation-targets@^7.22.15", "@babel/helper-compilation-targets@^7.22.5", "@babel/helper-compilation-targets@^7.22.6":
+  version "7.22.15"
+  resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz#0698fc44551a26cf29f18d4662d5bf545a6cfc52"
+  integrity sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==
   dependencies:
-    "@babel/compat-data" "^7.19.0"
-    "@babel/helper-validator-option" "^7.18.6"
-    browserslist "^4.20.2"
-    semver "^6.3.0"
+    "@babel/compat-data" "^7.22.9"
+    "@babel/helper-validator-option" "^7.22.15"
+    browserslist "^4.21.9"
+    lru-cache "^5.1.1"
+    semver "^6.3.1"
 
-"@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==
+"@babel/helper-create-class-features-plugin@^7.22.11", "@babel/helper-create-class-features-plugin@^7.22.5":
+  version "7.22.15"
+  resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.15.tgz#97a61b385e57fe458496fad19f8e63b63c867de4"
+  integrity sha512-jKkwA59IXcvSaiK2UN45kKwSC9o+KuoXsBDvHvU/7BecYIp8GQ2UwrVvFgJASUT+hBnwJx6MhvMCuMzwZZ7jlg==
   dependencies:
-    "@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-annotate-as-pure" "^7.22.5"
+    "@babel/helper-environment-visitor" "^7.22.5"
+    "@babel/helper-function-name" "^7.22.5"
+    "@babel/helper-member-expression-to-functions" "^7.22.15"
+    "@babel/helper-optimise-call-expression" "^7.22.5"
+    "@babel/helper-replace-supers" "^7.22.9"
+    "@babel/helper-skip-transparent-expression-wrappers" "^7.22.5"
+    "@babel/helper-split-export-declaration" "^7.22.6"
+    semver "^6.3.1"
 
-"@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==
+"@babel/helper-create-regexp-features-plugin@^7.18.6", "@babel/helper-create-regexp-features-plugin@^7.22.5":
+  version "7.22.15"
+  resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.15.tgz#5ee90093914ea09639b01c711db0d6775e558be1"
+  integrity sha512-29FkPLFjn4TPEa3RE7GpW+qbE8tlsu3jntNYNfcGsc49LphF1PQIiD+vMZ1z1xVOKt+93khA9tc2JBs3kBjA7w==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.18.6"
-    regexpu-core "^5.1.0"
+    "@babel/helper-annotate-as-pure" "^7.22.5"
+    regexpu-core "^5.3.1"
+    semver "^6.3.1"
 
-"@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==
+"@babel/helper-define-polyfill-provider@^0.4.2":
+  version "0.4.2"
+  resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.2.tgz#82c825cadeeeee7aad237618ebbe8fa1710015d7"
+  integrity sha512-k0qnnOqHn5dK9pZpfD5XXZ9SojAITdCKRn2Lp6rnDGzIbaP0rHyMPk/4wsSxVBVz4RfN0q6VpXWP2pDGIoQ7hw==
   dependencies:
-    "@babel/helper-compilation-targets" "^7.17.7"
-    "@babel/helper-plugin-utils" "^7.16.7"
+    "@babel/helper-compilation-targets" "^7.22.6"
+    "@babel/helper-plugin-utils" "^7.22.5"
     debug "^4.1.1"
     lodash.debounce "^4.0.8"
     resolve "^1.14.2"
-    semver "^6.1.2"
 
-"@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-environment-visitor@^7.22.20", "@babel/helper-environment-visitor@^7.22.5":
+  version "7.22.20"
+  resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz#96159db61d34a29dba454c959f5ae4a649ba9167"
+  integrity sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==
 
-"@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==
+"@babel/helper-function-name@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz#ede300828905bb15e582c037162f99d5183af1be"
+  integrity sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==
   dependencies:
-    "@babel/types" "^7.18.6"
+    "@babel/template" "^7.22.5"
+    "@babel/types" "^7.22.5"
 
-"@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==
+"@babel/helper-hoist-variables@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz#c01a007dac05c085914e8fb652b339db50d823bb"
+  integrity sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==
   dependencies:
-    "@babel/template" "^7.18.10"
-    "@babel/types" "^7.19.0"
+    "@babel/types" "^7.22.5"
 
-"@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==
+"@babel/helper-member-expression-to-functions@^7.22.15":
+  version "7.22.15"
+  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.22.15.tgz#b95a144896f6d491ca7863576f820f3628818621"
+  integrity sha512-qLNsZbgrNh0fDQBCPocSL8guki1hcPvltGDv/NxvUoABwFq7GkKSu1nRXeJkVZc+wJvne2E0RKQz+2SQrz6eAA==
   dependencies:
-    "@babel/types" "^7.18.6"
+    "@babel/types" "^7.22.15"
 
-"@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==
+"@babel/helper-module-imports@^7.22.15", "@babel/helper-module-imports@^7.22.5":
+  version "7.22.15"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz#16146307acdc40cc00c3b2c647713076464bdbf0"
+  integrity sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==
   dependencies:
-    "@babel/types" "^7.18.9"
+    "@babel/types" "^7.22.15"
 
-"@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==
+"@babel/helper-module-transforms@^7.22.15", "@babel/helper-module-transforms@^7.22.20", "@babel/helper-module-transforms@^7.22.5", "@babel/helper-module-transforms@^7.22.9":
+  version "7.22.20"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.22.20.tgz#da9edc14794babbe7386df438f3768067132f59e"
+  integrity sha512-dLT7JVWIUUxKOs1UnJUBR3S70YK+pKX6AbJgB2vMIvEkZkrfJDbYDJesnPshtKV4LhDOR3Oc5YULeDizRek+5A==
   dependencies:
-    "@babel/types" "^7.18.6"
+    "@babel/helper-environment-visitor" "^7.22.20"
+    "@babel/helper-module-imports" "^7.22.15"
+    "@babel/helper-simple-access" "^7.22.5"
+    "@babel/helper-split-export-declaration" "^7.22.6"
+    "@babel/helper-validator-identifier" "^7.22.20"
 
-"@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==
+"@babel/helper-optimise-call-expression@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz#f21531a9ccbff644fdd156b4077c16ff0c3f609e"
+  integrity sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==
   dependencies:
-    "@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/types" "^7.22.5"
 
-"@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==
+"@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.18.6", "@babel/helper-plugin-utils@^7.20.2", "@babel/helper-plugin-utils@^7.22.5", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz#dd7ee3735e8a313b9f7b05a773d892e88e6d7295"
+  integrity sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==
+
+"@babel/helper-remap-async-to-generator@^7.22.5", "@babel/helper-remap-async-to-generator@^7.22.9":
+  version "7.22.20"
+  resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.20.tgz#7b68e1cb4fa964d2996fd063723fb48eca8498e0"
+  integrity sha512-pBGyV4uBqOns+0UvhsTO8qgl8hO89PmiDYv+/COyp1aeMcmfrfruz+/nCMFiYyFF/Knn0yfrC85ZzNFjembFTw==
   dependencies:
-    "@babel/types" "^7.18.6"
+    "@babel/helper-annotate-as-pure" "^7.22.5"
+    "@babel/helper-environment-visitor" "^7.22.20"
+    "@babel/helper-wrap-function" "^7.22.20"
 
-"@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==
+"@babel/helper-replace-supers@^7.22.5", "@babel/helper-replace-supers@^7.22.9":
+  version "7.22.20"
+  resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.22.20.tgz#e37d367123ca98fe455a9887734ed2e16eb7a793"
+  integrity sha512-qsW0In3dbwQUbK8kejJ4R7IHVGwHJlV6lpG6UA7a9hSa2YEiAib+N1T2kr6PEeUT+Fl7najmSOS6SmAwCHK6Tw==
   dependencies:
-    "@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-environment-visitor" "^7.22.20"
+    "@babel/helper-member-expression-to-functions" "^7.22.15"
+    "@babel/helper-optimise-call-expression" "^7.22.5"
 
-"@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==
+"@babel/helper-simple-access@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz#4938357dc7d782b80ed6dbb03a0fba3d22b1d5de"
+  integrity sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==
   dependencies:
-    "@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/types" "^7.22.5"
 
-"@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==
+"@babel/helper-skip-transparent-expression-wrappers@^7.20.0", "@babel/helper-skip-transparent-expression-wrappers@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz#007f15240b5751c537c40e77abb4e89eeaaa8847"
+  integrity sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==
   dependencies:
-    "@babel/types" "^7.18.6"
+    "@babel/types" "^7.22.5"
 
-"@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==
+"@babel/helper-split-export-declaration@^7.22.6":
+  version "7.22.6"
+  resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz#322c61b7310c0997fe4c323955667f18fcefb91c"
+  integrity sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==
   dependencies:
-    "@babel/types" "^7.18.9"
+    "@babel/types" "^7.22.5"
 
-"@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==
+"@babel/helper-string-parser@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz#533f36457a25814cf1df6488523ad547d784a99f"
+  integrity sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==
+
+"@babel/helper-validator-identifier@^7.22.19", "@babel/helper-validator-identifier@^7.22.20", "@babel/helper-validator-identifier@^7.22.5":
+  version "7.22.20"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0"
+  integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==
+
+"@babel/helper-validator-option@^7.22.15":
+  version "7.22.15"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz#694c30dfa1d09a6534cdfcafbe56789d36aba040"
+  integrity sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==
+
+"@babel/helper-wrap-function@^7.22.20":
+  version "7.22.20"
+  resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.22.20.tgz#15352b0b9bfb10fc9c76f79f6342c00e3411a569"
+  integrity sha512-pms/UwkOpnQe/PDAEdV/d7dVCoBbB+R4FvYoHGZz+4VPcg7RtYy2KP7S2lbuWM6FCSgob5wshfGESbC/hzNXZw==
   dependencies:
-    "@babel/types" "^7.18.6"
+    "@babel/helper-function-name" "^7.22.5"
+    "@babel/template" "^7.22.15"
+    "@babel/types" "^7.22.19"
 
-"@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==
+"@babel/helpers@^7.22.15":
+  version "7.22.15"
+  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.22.15.tgz#f09c3df31e86e3ea0b7ff7556d85cdebd47ea6f1"
+  integrity sha512-7pAjK0aSdxOwR+CcYAqgWOGy5dcfvzsTIfFTb2odQqW47MDfv14UaJDY6eng8ylM2EaeKXdxaSWESbkmaQHTmw==
   dependencies:
-    "@babel/helper-function-name" "^7.19.0"
-    "@babel/template" "^7.18.10"
-    "@babel/traverse" "^7.19.0"
-    "@babel/types" "^7.19.0"
+    "@babel/template" "^7.22.15"
+    "@babel/traverse" "^7.22.15"
+    "@babel/types" "^7.22.15"
 
-"@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==
+"@babel/highlight@^7.22.13":
+  version "7.22.20"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.20.tgz#4ca92b71d80554b01427815e06f2df965b9c1f54"
+  integrity sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==
   dependencies:
-    "@babel/template" "^7.18.10"
-    "@babel/traverse" "^7.19.0"
-    "@babel/types" "^7.19.0"
-
-"@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/helper-validator-identifier" "^7.15.7"
-    chalk "^2.0.0"
+    "@babel/helper-validator-identifier" "^7.22.20"
+    chalk "^2.4.2"
     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==
+"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.22.15", "@babel/parser@^7.22.16", "@babel/parser@^7.4.3":
+  version "7.22.16"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.22.16.tgz#180aead7f247305cce6551bea2720934e2fa2c95"
+  integrity sha512-+gPfKv8UWeKKeJTUxe59+OobVcrYHETCsORl61EmSkmgymguYk/X5bp7GuUIXaFsc6y++v8ZxPsLSSuujqDphA==
+
+"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.22.15":
+  version "7.22.15"
+  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.22.15.tgz#02dc8a03f613ed5fdc29fb2f728397c78146c962"
+  integrity sha512-FB9iYlz7rURmRJyXRKEnalYPPdn87H5no108cyuQQyMwlpJ2SJtpIUBI27kdTin956pz+LPypkPVPUTlxOmrsg==
   dependencies:
-    "@babel/helper-validator-identifier" "^7.18.6"
-    chalk "^2.0.0"
-    js-tokens "^4.0.0"
+    "@babel/helper-plugin-utils" "^7.22.5"
 
-"@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==
+"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.22.15":
+  version "7.22.15"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.22.15.tgz#2aeb91d337d4e1a1e7ce85b76a37f5301781200f"
+  integrity sha512-Hyph9LseGvAeeXzikV88bczhsrLrIZqDPxO+sSmAunMPaGrBGhfMWzCPYTtiW9t+HzSE2wtV8e5cc5P6r1xMDQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.22.5"
+    "@babel/helper-skip-transparent-expression-wrappers" "^7.22.5"
+    "@babel/plugin-transform-optional-chaining" "^7.22.15"
 
-"@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.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.18.6"
-    "@babel/helper-plugin-utils" "^7.18.6"
-
-"@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.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.18.6":
+"@babel/plugin-proposal-dynamic-import@^7.10.4":
   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==
@@ -339,31 +289,7 @@
     "@babel/helper-plugin-utils" "^7.18.6"
     "@babel/plugin-syntax-dynamic-import" "^7.8.3"
 
-"@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.18.9"
-    "@babel/plugin-syntax-export-namespace-from" "^7.8.3"
-
-"@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.18.6"
-    "@babel/plugin-syntax-json-strings" "^7.8.3"
-
-"@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.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.18.6":
+"@babel/plugin-proposal-nullish-coalescing-operator@^7.10.4":
   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==
@@ -371,67 +297,19 @@
     "@babel/helper-plugin-utils" "^7.18.6"
     "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3"
 
-"@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==
+"@babel/plugin-proposal-optional-chaining@^7.11.0":
+  version "7.21.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.21.0.tgz#886f5c8978deb7d30f678b2e24346b287234d3ea"
+  integrity sha512-p4zeefM72gpmEe2fkUr/OnOXpWEf8nAgk7ZYVqqfFiyIG7oFfVZcCrU64hWn5xp4tQ9LkV4bTIa5rD0KANpKNA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.18.6"
-    "@babel/plugin-syntax-numeric-separator" "^7.10.4"
-
-"@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.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.18.8"
-
-"@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.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.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.18.9"
-    "@babel/helper-skip-transparent-expression-wrappers" "^7.18.9"
+    "@babel/helper-plugin-utils" "^7.20.2"
+    "@babel/helper-skip-transparent-expression-wrappers" "^7.20.0"
     "@babel/plugin-syntax-optional-chaining" "^7.8.3"
 
-"@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.18.6"
-    "@babel/helper-plugin-utils" "^7.18.6"
-
-"@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.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.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.18.6"
-    "@babel/helper-plugin-utils" "^7.18.6"
+"@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2":
+  version "7.21.0-placeholder-for-preset-env.2"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz#7844f9289546efa9febac2de4cfe358a050bd703"
+  integrity sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==
 
 "@babel/plugin-syntax-async-generators@^7.8.4":
   version "7.8.4"
@@ -468,12 +346,19 @@
   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==
+"@babel/plugin-syntax-import-assertions@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.22.5.tgz#07d252e2aa0bc6125567f742cd58619cb14dce98"
+  integrity sha512-rdV97N7KqsRzeNGoWUOK6yUsWarLjE5Su/Snk9IYPU9CwkWHs4t+rTGOvffTR8XGkJMTAdLfO0xVnXm8wugIJg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.22.5"
+
+"@babel/plugin-syntax-import-attributes@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.22.5.tgz#ab840248d834410b829f569f5262b9e517555ecb"
+  integrity sha512-KwvoWDeNKPETmozyFE0P2rOLqh39EoQHNjqizrI5B8Vt0ZNS7M56s7dAiAqbYfiAYOuIzIh96z3iR2ktgu3tEg==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.22.5"
 
 "@babel/plugin-syntax-import-meta@^7.10.4":
   version "7.10.4"
@@ -545,291 +430,422 @@
   dependencies:
     "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-arrow-functions@^7.18.6":
+"@babel/plugin-syntax-unicode-sets-regex@^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==
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz#d49a3b3e6b52e5be6740022317580234a6a47357"
+  integrity sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==
   dependencies:
+    "@babel/helper-create-regexp-features-plugin" "^7.18.6"
     "@babel/helper-plugin-utils" "^7.18.6"
 
-"@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==
+"@babel/plugin-transform-arrow-functions@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.22.5.tgz#e5ba566d0c58a5b2ba2a8b795450641950b71958"
+  integrity sha512-26lTNXoVRdAnsaDXPpvCNUq+OVWEVC6bx7Vvz9rC53F2bagUWW4u4ii2+h8Fejfh7RYqPxn+libeFBBck9muEw==
   dependencies:
-    "@babel/helper-module-imports" "^7.18.6"
-    "@babel/helper-plugin-utils" "^7.18.6"
-    "@babel/helper-remap-async-to-generator" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.22.5"
 
-"@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==
+"@babel/plugin-transform-async-generator-functions@^7.22.15":
+  version "7.22.15"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.22.15.tgz#3b153af4a6b779f340d5b80d3f634f55820aefa3"
+  integrity sha512-jBm1Es25Y+tVoTi5rfd5t1KLmL8ogLKpXszboWOTTtGFGz2RKnQe2yn7HbZ+kb/B8N0FVSGQo874NSlOU1T4+w==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.18.6"
+    "@babel/helper-environment-visitor" "^7.22.5"
+    "@babel/helper-plugin-utils" "^7.22.5"
+    "@babel/helper-remap-async-to-generator" "^7.22.9"
+    "@babel/plugin-syntax-async-generators" "^7.8.4"
 
-"@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==
+"@babel/plugin-transform-async-to-generator@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.22.5.tgz#c7a85f44e46f8952f6d27fe57c2ed3cc084c3775"
+  integrity sha512-b1A8D8ZzE/VhNDoV1MSJTnpKkCG5bJo+19R4o4oy03zM7ws8yEMK755j61Dc3EyvdysbqH5BOOTquJ7ZX9C6vQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.18.9"
+    "@babel/helper-module-imports" "^7.22.5"
+    "@babel/helper-plugin-utils" "^7.22.5"
+    "@babel/helper-remap-async-to-generator" "^7.22.5"
 
-"@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==
+"@babel/plugin-transform-block-scoped-functions@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.22.5.tgz#27978075bfaeb9fa586d3cb63a3d30c1de580024"
+  integrity sha512-tdXZ2UdknEKQWKJP1KMNmuF5Lx3MymtMN/pvA+p/VEkhK8jVcQ1fzSy8KM9qRYhAf2/lV33hoMPKI/xaI9sADA==
   dependencies:
-    "@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"
+    "@babel/helper-plugin-utils" "^7.22.5"
+
+"@babel/plugin-transform-block-scoping@^7.22.15":
+  version "7.22.15"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.22.15.tgz#494eb82b87b5f8b1d8f6f28ea74078ec0a10a841"
+  integrity sha512-G1czpdJBZCtngoK1sJgloLiOHUnkb/bLZwqVZD8kXmq0ZnVfTTWUcs9OWtp0mBtYJ+4LQY1fllqBkOIPhXmFmw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.22.5"
+
+"@babel/plugin-transform-class-properties@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.22.5.tgz#97a56e31ad8c9dc06a0b3710ce7803d5a48cca77"
+  integrity sha512-nDkQ0NfkOhPTq8YCLiWNxp1+f9fCobEjCb0n8WdbNUBc4IB5V7P1QnX9IjpSoquKrXF5SKojHleVNs2vGeHCHQ==
+  dependencies:
+    "@babel/helper-create-class-features-plugin" "^7.22.5"
+    "@babel/helper-plugin-utils" "^7.22.5"
+
+"@babel/plugin-transform-class-static-block@^7.22.11":
+  version "7.22.11"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.22.11.tgz#dc8cc6e498f55692ac6b4b89e56d87cec766c974"
+  integrity sha512-GMM8gGmqI7guS/llMFk1bJDkKfn3v3C4KHK9Yg1ey5qcHcOlKb0QvcMrgzvxo+T03/4szNh5lghY+fEC98Kq9g==
+  dependencies:
+    "@babel/helper-create-class-features-plugin" "^7.22.11"
+    "@babel/helper-plugin-utils" "^7.22.5"
+    "@babel/plugin-syntax-class-static-block" "^7.14.5"
+
+"@babel/plugin-transform-classes@^7.22.15":
+  version "7.22.15"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.22.15.tgz#aaf4753aee262a232bbc95451b4bdf9599c65a0b"
+  integrity sha512-VbbC3PGjBdE0wAWDdHM9G8Gm977pnYI0XpqMd6LrKISj8/DJXEsWqgRuTYaNE9Bv0JGhTZUzHDlMk18IpOuoqw==
+  dependencies:
+    "@babel/helper-annotate-as-pure" "^7.22.5"
+    "@babel/helper-compilation-targets" "^7.22.15"
+    "@babel/helper-environment-visitor" "^7.22.5"
+    "@babel/helper-function-name" "^7.22.5"
+    "@babel/helper-optimise-call-expression" "^7.22.5"
+    "@babel/helper-plugin-utils" "^7.22.5"
+    "@babel/helper-replace-supers" "^7.22.9"
+    "@babel/helper-split-export-declaration" "^7.22.6"
     globals "^11.1.0"
 
-"@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==
+"@babel/plugin-transform-computed-properties@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.22.5.tgz#cd1e994bf9f316bd1c2dafcd02063ec261bb3869"
+  integrity sha512-4GHWBgRf0krxPX+AaPtgBAlTgTeZmqDynokHOX7aqqAB4tHs3U2Y02zH6ETFdLZGcg9UQSD1WCmkVrE9ErHeOg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.18.9"
+    "@babel/helper-plugin-utils" "^7.22.5"
+    "@babel/template" "^7.22.5"
 
-"@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==
+"@babel/plugin-transform-destructuring@^7.22.15":
+  version "7.22.15"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.22.15.tgz#e7404ea5bb3387073b9754be654eecb578324694"
+  integrity sha512-HzG8sFl1ZVGTme74Nw+X01XsUTqERVQ6/RLHo3XjGRzm7XD6QTtfS3NJotVgCGy8BzkDqRjRBD8dAyJn5TuvSQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.18.9"
+    "@babel/helper-plugin-utils" "^7.22.5"
 
-"@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==
+"@babel/plugin-transform-dotall-regex@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.22.5.tgz#dbb4f0e45766eb544e193fb00e65a1dd3b2a4165"
+  integrity sha512-5/Yk9QxCQCl+sOIB1WelKnVRxTJDSAIxtJLL2/pqL14ZVlbH0fUQUZa/T5/UnQtBNgghR7mfB8ERBKyKPCi7Vw==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.18.6"
-    "@babel/helper-plugin-utils" "^7.18.6"
+    "@babel/helper-create-regexp-features-plugin" "^7.22.5"
+    "@babel/helper-plugin-utils" "^7.22.5"
 
-"@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==
+"@babel/plugin-transform-duplicate-keys@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.22.5.tgz#b6e6428d9416f5f0bba19c70d1e6e7e0b88ab285"
+  integrity sha512-dEnYD+9BBgld5VBXHnF/DbYGp3fqGMsyxKbtD1mDyIA7AkTSpKXFhCVuj/oQVOoALfBs77DudA0BE4d5mcpmqw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.18.9"
+    "@babel/helper-plugin-utils" "^7.22.5"
 
-"@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==
+"@babel/plugin-transform-dynamic-import@^7.22.11":
+  version "7.22.11"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.22.11.tgz#2c7722d2a5c01839eaf31518c6ff96d408e447aa"
+  integrity sha512-g/21plo58sfteWjaO0ZNVb+uEOkJNjAaHhbejrnBmu011l/eNDScmkbjCC3l4FKb10ViaGU4aOkFznSu2zRHgA==
   dependencies:
-    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.18.6"
-    "@babel/helper-plugin-utils" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.22.5"
+    "@babel/plugin-syntax-dynamic-import" "^7.8.3"
 
-"@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==
+"@babel/plugin-transform-exponentiation-operator@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.22.5.tgz#402432ad544a1f9a480da865fda26be653e48f6a"
+  integrity sha512-vIpJFNM/FjZ4rh1myqIya9jXwrwwgFRHPjT3DkUA9ZLHuzox8jiXkOLvwm1H+PQIP3CqfC++WPKeuDi0Sjdj1g==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.18.6"
+    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.22.5"
+    "@babel/helper-plugin-utils" "^7.22.5"
 
-"@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==
+"@babel/plugin-transform-export-namespace-from@^7.22.11":
+  version "7.22.11"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.22.11.tgz#b3c84c8f19880b6c7440108f8929caf6056db26c"
+  integrity sha512-xa7aad7q7OiT8oNZ1mU7NrISjlSkVdMbNxn9IuLZyL9AJEhs1Apba3I+u5riX1dIkdptP5EKDG5XDPByWxtehw==
   dependencies:
-    "@babel/helper-compilation-targets" "^7.18.9"
-    "@babel/helper-function-name" "^7.18.9"
-    "@babel/helper-plugin-utils" "^7.18.9"
+    "@babel/helper-plugin-utils" "^7.22.5"
+    "@babel/plugin-syntax-export-namespace-from" "^7.8.3"
 
-"@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==
+"@babel/plugin-transform-for-of@^7.22.15":
+  version "7.22.15"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.22.15.tgz#f64b4ccc3a4f131a996388fae7680b472b306b29"
+  integrity sha512-me6VGeHsx30+xh9fbDLLPi0J1HzmeIIyenoOQHuw2D4m2SAU3NrspX5XxJLBpqn5yrLzrlw2Iy3RA//Bx27iOA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.18.9"
+    "@babel/helper-plugin-utils" "^7.22.5"
 
-"@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==
+"@babel/plugin-transform-function-name@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.22.5.tgz#935189af68b01898e0d6d99658db6b164205c143"
+  integrity sha512-UIzQNMS0p0HHiQm3oelztj+ECwFnj+ZRV4KnguvlsD2of1whUeM6o7wGNj6oLwcDoAXQ8gEqfgC24D+VdIcevg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.18.6"
+    "@babel/helper-compilation-targets" "^7.22.5"
+    "@babel/helper-function-name" "^7.22.5"
+    "@babel/helper-plugin-utils" "^7.22.5"
 
-"@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==
+"@babel/plugin-transform-json-strings@^7.22.11":
+  version "7.22.11"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.22.11.tgz#689a34e1eed1928a40954e37f74509f48af67835"
+  integrity sha512-CxT5tCqpA9/jXFlme9xIBCc5RPtdDq3JpkkhgHQqtDdiTnTI0jtZ0QzXhr5DILeYifDPp2wvY2ad+7+hLMW5Pw==
   dependencies:
-    "@babel/helper-module-transforms" "^7.18.6"
-    "@babel/helper-plugin-utils" "^7.18.6"
-    babel-plugin-dynamic-import-node "^2.3.3"
+    "@babel/helper-plugin-utils" "^7.22.5"
+    "@babel/plugin-syntax-json-strings" "^7.8.3"
 
-"@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==
+"@babel/plugin-transform-literals@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.22.5.tgz#e9341f4b5a167952576e23db8d435849b1dd7920"
+  integrity sha512-fTLj4D79M+mepcw3dgFBTIDYpbcB9Sm0bpm4ppXPaO+U+PKFFyV9MGRvS0gvGw62sd10kT5lRMKXAADb9pWy8g==
   dependencies:
-    "@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/helper-plugin-utils" "^7.22.5"
 
-"@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==
+"@babel/plugin-transform-logical-assignment-operators@^7.22.11":
+  version "7.22.11"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.22.11.tgz#24c522a61688bde045b7d9bc3c2597a4d948fc9c"
+  integrity sha512-qQwRTP4+6xFCDV5k7gZBF3C31K34ut0tbEcTKxlX/0KXxm9GLcO14p570aWxFvVzx6QAfPgq7gaeIHXJC8LswQ==
   dependencies:
-    "@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/helper-plugin-utils" "^7.22.5"
+    "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4"
 
-"@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==
+"@babel/plugin-transform-member-expression-literals@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.22.5.tgz#4fcc9050eded981a468347dd374539ed3e058def"
+  integrity sha512-RZEdkNtzzYCFl9SE9ATaUMTj2hqMb4StarOJLrZRbqqU4HSBE7UlBw9WBWQiDzrJZJdUWiMTVDI6Gv/8DPvfew==
   dependencies:
-    "@babel/helper-module-transforms" "^7.18.6"
-    "@babel/helper-plugin-utils" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.22.5"
 
-"@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==
+"@babel/plugin-transform-modules-amd@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.22.5.tgz#4e045f55dcf98afd00f85691a68fc0780704f526"
+  integrity sha512-R+PTfLTcYEmb1+kK7FNkhQ1gP4KgjpSO6HfH9+f8/yfp2Nt3ggBjiVpRwmwTlfqZLafYKJACy36yDXlEmI9HjQ==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.19.0"
-    "@babel/helper-plugin-utils" "^7.19.0"
+    "@babel/helper-module-transforms" "^7.22.5"
+    "@babel/helper-plugin-utils" "^7.22.5"
 
-"@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==
+"@babel/plugin-transform-modules-commonjs@^7.22.15":
+  version "7.22.15"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.22.15.tgz#b11810117ed4ee7691b29bd29fd9f3f98276034f"
+  integrity sha512-jWL4eh90w0HQOTKP2MoXXUpVxilxsB2Vl4ji69rSjS3EcZ/v4sBmn+A3NpepuJzBhOaEBbR7udonlHHn5DWidg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.18.6"
+    "@babel/helper-module-transforms" "^7.22.15"
+    "@babel/helper-plugin-utils" "^7.22.5"
+    "@babel/helper-simple-access" "^7.22.5"
 
-"@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==
+"@babel/plugin-transform-modules-systemjs@^7.22.11":
+  version "7.22.11"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.22.11.tgz#3386be5875d316493b517207e8f1931d93154bb1"
+  integrity sha512-rIqHmHoMEOhI3VkVf5jQ15l539KrwhzqcBO6wdCNWPWc/JWt9ILNYNUssbRpeq0qWns8svuw8LnMNCvWBIJ8wA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.18.6"
-    "@babel/helper-replace-supers" "^7.18.6"
+    "@babel/helper-hoist-variables" "^7.22.5"
+    "@babel/helper-module-transforms" "^7.22.9"
+    "@babel/helper-plugin-utils" "^7.22.5"
+    "@babel/helper-validator-identifier" "^7.22.5"
 
-"@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==
+"@babel/plugin-transform-modules-umd@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.22.5.tgz#4694ae40a87b1745e3775b6a7fe96400315d4f98"
+  integrity sha512-+S6kzefN/E1vkSsKx8kmQuqeQsvCKCd1fraCM7zXm4SFoggI099Tr4G8U81+5gtMdUeMQ4ipdQffbKLX0/7dBQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.18.6"
+    "@babel/helper-module-transforms" "^7.22.5"
+    "@babel/helper-plugin-utils" "^7.22.5"
 
-"@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==
+"@babel/plugin-transform-named-capturing-groups-regex@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.22.5.tgz#67fe18ee8ce02d57c855185e27e3dc959b2e991f"
+  integrity sha512-YgLLKmS3aUBhHaxp5hi1WJTgOUb/NCuDHzGT9z9WTt3YG+CPRhJs6nprbStx6DnWM4dh6gt7SU3sZodbZ08adQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.18.6"
+    "@babel/helper-create-regexp-features-plugin" "^7.22.5"
+    "@babel/helper-plugin-utils" "^7.22.5"
 
-"@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==
+"@babel/plugin-transform-new-target@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.22.5.tgz#1b248acea54ce44ea06dfd37247ba089fcf9758d"
+  integrity sha512-AsF7K0Fx/cNKVyk3a+DW0JLo+Ua598/NxMRvxDnkpCIGFh43+h/v2xyhRUYf6oD8gE4QtL83C7zZVghMjHd+iw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.18.6"
-    regenerator-transform "^0.15.0"
+    "@babel/helper-plugin-utils" "^7.22.5"
 
-"@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==
+"@babel/plugin-transform-nullish-coalescing-operator@^7.22.11":
+  version "7.22.11"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.22.11.tgz#debef6c8ba795f5ac67cd861a81b744c5d38d9fc"
+  integrity sha512-YZWOw4HxXrotb5xsjMJUDlLgcDXSfO9eCmdl1bgW4+/lAGdkjaEvOnQ4p5WKKdUgSzO39dgPl0pTnfxm0OAXcg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.22.5"
+    "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3"
 
-"@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==
+"@babel/plugin-transform-numeric-separator@^7.22.11":
+  version "7.22.11"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.22.11.tgz#498d77dc45a6c6db74bb829c02a01c1d719cbfbd"
+  integrity sha512-3dzU4QGPsILdJbASKhF/V2TVP+gJya1PsueQCxIPCEcerqF21oEcrob4mzjsp2Py/1nLfF5m+xYNMDpmA8vffg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.22.5"
+    "@babel/plugin-syntax-numeric-separator" "^7.10.4"
 
-"@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==
+"@babel/plugin-transform-object-rest-spread@^7.22.15":
+  version "7.22.15"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.22.15.tgz#21a95db166be59b91cde48775310c0df6e1da56f"
+  integrity sha512-fEB+I1+gAmfAyxZcX1+ZUwLeAuuf8VIg67CTznZE0MqVFumWkh8xWtn58I4dxdVf080wn7gzWoF8vndOViJe9Q==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.19.0"
-    "@babel/helper-skip-transparent-expression-wrappers" "^7.18.9"
+    "@babel/compat-data" "^7.22.9"
+    "@babel/helper-compilation-targets" "^7.22.15"
+    "@babel/helper-plugin-utils" "^7.22.5"
+    "@babel/plugin-syntax-object-rest-spread" "^7.8.3"
+    "@babel/plugin-transform-parameters" "^7.22.15"
 
-"@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==
+"@babel/plugin-transform-object-super@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.22.5.tgz#794a8d2fcb5d0835af722173c1a9d704f44e218c"
+  integrity sha512-klXqyaT9trSjIUrcsYIfETAzmOEZL3cBYqOYLJxBHfMFFggmXOv+NYSX/Jbs9mzMVESw/WycLFPRx8ba/b2Ipw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.22.5"
+    "@babel/helper-replace-supers" "^7.22.5"
 
-"@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==
+"@babel/plugin-transform-optional-catch-binding@^7.22.11":
+  version "7.22.11"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.22.11.tgz#461cc4f578a127bb055527b3e77404cad38c08e0"
+  integrity sha512-rli0WxesXUeCJnMYhzAglEjLWVDF6ahb45HuprcmQuLidBJFWjNnOzssk2kuc6e33FlLaiZhG/kUIzUMWdBKaQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.18.9"
+    "@babel/helper-plugin-utils" "^7.22.5"
+    "@babel/plugin-syntax-optional-catch-binding" "^7.8.3"
 
-"@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==
+"@babel/plugin-transform-optional-chaining@^7.22.15":
+  version "7.22.15"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.22.15.tgz#d7a5996c2f7ca4ad2ad16dbb74444e5c4385b1ba"
+  integrity sha512-ngQ2tBhq5vvSJw2Q2Z9i7ealNkpDMU0rGWnHPKqRZO0tzZ5tlaoz4hDvhXioOoaE0X2vfNss1djwg0DXlfu30A==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.18.9"
+    "@babel/helper-plugin-utils" "^7.22.5"
+    "@babel/helper-skip-transparent-expression-wrappers" "^7.22.5"
+    "@babel/plugin-syntax-optional-chaining" "^7.8.3"
 
-"@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==
+"@babel/plugin-transform-parameters@^7.22.15":
+  version "7.22.15"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.22.15.tgz#719ca82a01d177af358df64a514d64c2e3edb114"
+  integrity sha512-hjk7qKIqhyzhhUvRT683TYQOFa/4cQKwQy7ALvTpODswN40MljzNDa0YldevS6tGbxwaEKVn502JmY0dP7qEtQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.18.9"
+    "@babel/helper-plugin-utils" "^7.22.5"
 
-"@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==
+"@babel/plugin-transform-private-methods@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.22.5.tgz#21c8af791f76674420a147ae62e9935d790f8722"
+  integrity sha512-PPjh4gyrQnGe97JTalgRGMuU4icsZFnWkzicB/fUtzlKUqvsWBKEpPPfr5a2JiyirZkHxnAqkQMO5Z5B2kK3fA==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.18.6"
-    "@babel/helper-plugin-utils" "^7.18.6"
+    "@babel/helper-create-class-features-plugin" "^7.22.5"
+    "@babel/helper-plugin-utils" "^7.22.5"
+
+"@babel/plugin-transform-private-property-in-object@^7.22.11":
+  version "7.22.11"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.22.11.tgz#ad45c4fc440e9cb84c718ed0906d96cf40f9a4e1"
+  integrity sha512-sSCbqZDBKHetvjSwpyWzhuHkmW5RummxJBVbYLkGkaiTOWGxml7SXt0iWa03bzxFIx7wOj3g/ILRd0RcJKBeSQ==
+  dependencies:
+    "@babel/helper-annotate-as-pure" "^7.22.5"
+    "@babel/helper-create-class-features-plugin" "^7.22.11"
+    "@babel/helper-plugin-utils" "^7.22.5"
+    "@babel/plugin-syntax-private-property-in-object" "^7.14.5"
+
+"@babel/plugin-transform-property-literals@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.22.5.tgz#b5ddabd73a4f7f26cd0e20f5db48290b88732766"
+  integrity sha512-TiOArgddK3mK/x1Qwf5hay2pxI6wCZnvQqrFSqbtg1GLl2JcNMitVH/YnqjP+M31pLUeTfzY1HAXFDnUBV30rQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.22.5"
+
+"@babel/plugin-transform-regenerator@^7.22.10":
+  version "7.22.10"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.22.10.tgz#8ceef3bd7375c4db7652878b0241b2be5d0c3cca"
+  integrity sha512-F28b1mDt8KcT5bUyJc/U9nwzw6cV+UmTeRlXYIl2TNqMMJif0Jeey9/RQ3C4NOd2zp0/TRsDns9ttj2L523rsw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.22.5"
+    regenerator-transform "^0.15.2"
+
+"@babel/plugin-transform-reserved-words@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.22.5.tgz#832cd35b81c287c4bcd09ce03e22199641f964fb"
+  integrity sha512-DTtGKFRQUDm8svigJzZHzb/2xatPc6TzNvAIJ5GqOKDsGFYgAskjRulbR/vGsPKq3OPqtexnz327qYpP57RFyA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.22.5"
+
+"@babel/plugin-transform-shorthand-properties@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.22.5.tgz#6e277654be82b5559fc4b9f58088507c24f0c624"
+  integrity sha512-vM4fq9IXHscXVKzDv5itkO1X52SmdFBFcMIBZ2FRn2nqVYqw6dBexUgMvAjHW+KXpPPViD/Yo3GrDEBaRC0QYA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.22.5"
+
+"@babel/plugin-transform-spread@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.22.5.tgz#6487fd29f229c95e284ba6c98d65eafb893fea6b"
+  integrity sha512-5ZzDQIGyvN4w8+dMmpohL6MBo+l2G7tfC/O2Dg7/hjpgeWvUx8FzfeOKxGog9IimPa4YekaQ9PlDqTLOljkcxg==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.22.5"
+    "@babel/helper-skip-transparent-expression-wrappers" "^7.22.5"
+
+"@babel/plugin-transform-sticky-regex@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.22.5.tgz#295aba1595bfc8197abd02eae5fc288c0deb26aa"
+  integrity sha512-zf7LuNpHG0iEeiyCNwX4j3gDg1jgt1k3ZdXBKbZSoA3BbGQGvMiSvfbZRR3Dr3aeJe3ooWFZxOOG3IRStYp2Bw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.22.5"
+
+"@babel/plugin-transform-template-literals@^7.22.5", "@babel/plugin-transform-template-literals@^7.8.3":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.22.5.tgz#8f38cf291e5f7a8e60e9f733193f0bcc10909bff"
+  integrity sha512-5ciOehRNf+EyUeewo8NkbQiUs4d6ZxiHo6BcBcnFlgiJfu16q0bQUw9Jvo0b0gBKFG1SMhDSjeKXSYuJLeFSMA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.22.5"
+
+"@babel/plugin-transform-typeof-symbol@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.22.5.tgz#5e2ba478da4b603af8673ff7c54f75a97b716b34"
+  integrity sha512-bYkI5lMzL4kPii4HHEEChkD0rkc+nvnlR6+o/qdqR6zrm0Sv/nodmyLhlq2DO0YKLUNd2VePmPRjJXSBh9OIdA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.22.5"
+
+"@babel/plugin-transform-unicode-escapes@^7.22.10":
+  version "7.22.10"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.22.10.tgz#c723f380f40a2b2f57a62df24c9005834c8616d9"
+  integrity sha512-lRfaRKGZCBqDlRU3UIFovdp9c9mEvlylmpod0/OatICsSfuQ9YFthRo1tpTkGsklEefZdqlEFdY4A2dwTb6ohg==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.22.5"
+
+"@babel/plugin-transform-unicode-property-regex@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.22.5.tgz#098898f74d5c1e86660dc112057b2d11227f1c81"
+  integrity sha512-HCCIb+CbJIAE6sXn5CjFQXMwkCClcOfPCzTlilJ8cUatfzwHlWQkbtV0zD338u9dZskwvuOYTuuaMaA8J5EI5A==
+  dependencies:
+    "@babel/helper-create-regexp-features-plugin" "^7.22.5"
+    "@babel/helper-plugin-utils" "^7.22.5"
+
+"@babel/plugin-transform-unicode-regex@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.22.5.tgz#ce7e7bb3ef208c4ff67e02a22816656256d7a183"
+  integrity sha512-028laaOKptN5vHJf9/Arr/HiJekMd41hOEZYvNsrsXqJ7YPYuX2bQxh31fkZzGmq3YqHRJzYFFAVYvKfMPKqyg==
+  dependencies:
+    "@babel/helper-create-regexp-features-plugin" "^7.22.5"
+    "@babel/helper-plugin-utils" "^7.22.5"
+
+"@babel/plugin-transform-unicode-sets-regex@^7.22.5":
+  version "7.22.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.22.5.tgz#77788060e511b708ffc7d42fdfbc5b37c3004e91"
+  integrity sha512-lhMfi4FC15j13eKrh3DnYHjpGj6UKQHtNKTbtc1igvAhRy4+kLhV07OpLcsN0VgDEw/MjAvJO4BdMJsHwMhzCg==
+  dependencies:
+    "@babel/helper-create-regexp-features-plugin" "^7.22.5"
+    "@babel/helper-plugin-utils" "^7.22.5"
 
 "@babel/preset-env@^7.9.0":
-  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==
+  version "7.22.20"
+  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.22.20.tgz#de9e9b57e1127ce0a2f580831717f7fb677ceedb"
+  integrity sha512-11MY04gGC4kSzlPHRfvVkNAZhUxOvm7DCJ37hPDnUENwe06npjIRAfInEMTGSb4LZK5ZgDFkv5hw0lGebHeTyg==
   dependencies:
-    "@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/compat-data" "^7.22.20"
+    "@babel/helper-compilation-targets" "^7.22.15"
+    "@babel/helper-plugin-utils" "^7.22.5"
+    "@babel/helper-validator-option" "^7.22.15"
+    "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.22.15"
+    "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.22.15"
+    "@babel/plugin-proposal-private-property-in-object" "7.21.0-placeholder-for-preset-env.2"
     "@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-import-assertions" "^7.22.5"
+    "@babel/plugin-syntax-import-attributes" "^7.22.5"
+    "@babel/plugin-syntax-import-meta" "^7.10.4"
     "@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"
@@ -839,96 +855,116 @@
     "@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.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/plugin-syntax-unicode-sets-regex" "^7.18.6"
+    "@babel/plugin-transform-arrow-functions" "^7.22.5"
+    "@babel/plugin-transform-async-generator-functions" "^7.22.15"
+    "@babel/plugin-transform-async-to-generator" "^7.22.5"
+    "@babel/plugin-transform-block-scoped-functions" "^7.22.5"
+    "@babel/plugin-transform-block-scoping" "^7.22.15"
+    "@babel/plugin-transform-class-properties" "^7.22.5"
+    "@babel/plugin-transform-class-static-block" "^7.22.11"
+    "@babel/plugin-transform-classes" "^7.22.15"
+    "@babel/plugin-transform-computed-properties" "^7.22.5"
+    "@babel/plugin-transform-destructuring" "^7.22.15"
+    "@babel/plugin-transform-dotall-regex" "^7.22.5"
+    "@babel/plugin-transform-duplicate-keys" "^7.22.5"
+    "@babel/plugin-transform-dynamic-import" "^7.22.11"
+    "@babel/plugin-transform-exponentiation-operator" "^7.22.5"
+    "@babel/plugin-transform-export-namespace-from" "^7.22.11"
+    "@babel/plugin-transform-for-of" "^7.22.15"
+    "@babel/plugin-transform-function-name" "^7.22.5"
+    "@babel/plugin-transform-json-strings" "^7.22.11"
+    "@babel/plugin-transform-literals" "^7.22.5"
+    "@babel/plugin-transform-logical-assignment-operators" "^7.22.11"
+    "@babel/plugin-transform-member-expression-literals" "^7.22.5"
+    "@babel/plugin-transform-modules-amd" "^7.22.5"
+    "@babel/plugin-transform-modules-commonjs" "^7.22.15"
+    "@babel/plugin-transform-modules-systemjs" "^7.22.11"
+    "@babel/plugin-transform-modules-umd" "^7.22.5"
+    "@babel/plugin-transform-named-capturing-groups-regex" "^7.22.5"
+    "@babel/plugin-transform-new-target" "^7.22.5"
+    "@babel/plugin-transform-nullish-coalescing-operator" "^7.22.11"
+    "@babel/plugin-transform-numeric-separator" "^7.22.11"
+    "@babel/plugin-transform-object-rest-spread" "^7.22.15"
+    "@babel/plugin-transform-object-super" "^7.22.5"
+    "@babel/plugin-transform-optional-catch-binding" "^7.22.11"
+    "@babel/plugin-transform-optional-chaining" "^7.22.15"
+    "@babel/plugin-transform-parameters" "^7.22.15"
+    "@babel/plugin-transform-private-methods" "^7.22.5"
+    "@babel/plugin-transform-private-property-in-object" "^7.22.11"
+    "@babel/plugin-transform-property-literals" "^7.22.5"
+    "@babel/plugin-transform-regenerator" "^7.22.10"
+    "@babel/plugin-transform-reserved-words" "^7.22.5"
+    "@babel/plugin-transform-shorthand-properties" "^7.22.5"
+    "@babel/plugin-transform-spread" "^7.22.5"
+    "@babel/plugin-transform-sticky-regex" "^7.22.5"
+    "@babel/plugin-transform-template-literals" "^7.22.5"
+    "@babel/plugin-transform-typeof-symbol" "^7.22.5"
+    "@babel/plugin-transform-unicode-escapes" "^7.22.10"
+    "@babel/plugin-transform-unicode-property-regex" "^7.22.5"
+    "@babel/plugin-transform-unicode-regex" "^7.22.5"
+    "@babel/plugin-transform-unicode-sets-regex" "^7.22.5"
+    "@babel/preset-modules" "0.1.6-no-external-plugins"
+    "@babel/types" "^7.22.19"
+    babel-plugin-polyfill-corejs2 "^0.4.5"
+    babel-plugin-polyfill-corejs3 "^0.8.3"
+    babel-plugin-polyfill-regenerator "^0.5.2"
+    core-js-compat "^3.31.0"
+    semver "^6.3.1"
 
-"@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==
+"@babel/preset-modules@0.1.6-no-external-plugins":
+  version "0.1.6-no-external-plugins"
+  resolved "https://registry.yarnpkg.com/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz#ccb88a2c49c817236861fee7826080573b8a923a"
+  integrity sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==
   dependencies:
     "@babel/helper-plugin-utils" "^7.0.0"
-    "@babel/plugin-proposal-unicode-property-regex" "^7.4.4"
-    "@babel/plugin-transform-dotall-regex" "^7.4.4"
     "@babel/types" "^7.4.4"
     esutils "^2.0.2"
 
+"@babel/regjsgen@^0.8.0":
+  version "0.8.0"
+  resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310"
+  integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==
+
 "@babel/runtime@^7.8.4":
-  version "7.19.0"
-  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.19.0.tgz#22b11c037b094d27a8a2504ea4dcff00f50e2259"
-  integrity sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==
+  version "7.22.15"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.15.tgz#38f46494ccf6cf020bd4eed7124b425e83e523b8"
+  integrity sha512-T0O+aa+4w0u06iNmapipJXMV4HoUir03hpx3/YqXXhu9xim3w+dVphjFWl1OH8NbZHw5Lbm9k45drDkgq2VNNA==
   dependencies:
-    regenerator-runtime "^0.13.4"
+    regenerator-runtime "^0.14.0"
 
-"@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==
+"@babel/template@^7.22.15", "@babel/template@^7.22.5", "@babel/template@^7.4.0":
+  version "7.22.15"
+  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38"
+  integrity sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==
   dependencies:
-    "@babel/code-frame" "^7.18.6"
-    "@babel/parser" "^7.18.10"
-    "@babel/types" "^7.18.10"
+    "@babel/code-frame" "^7.22.13"
+    "@babel/parser" "^7.22.15"
+    "@babel/types" "^7.22.15"
 
-"@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==
+"@babel/traverse@^7.22.15", "@babel/traverse@^7.22.20", "@babel/traverse@^7.4.3":
+  version "7.22.20"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.22.20.tgz#db572d9cb5c79e02d83e5618b82f6991c07584c9"
+  integrity sha512-eU260mPZbU7mZ0N+X10pxXhQFMGTeLb9eFS0mxehS8HZp9o1uSnFeWQuG1UPrlxgA7QoUzFhOnilHDp0AXCyHw==
   dependencies:
-    "@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"
+    "@babel/code-frame" "^7.22.13"
+    "@babel/generator" "^7.22.15"
+    "@babel/helper-environment-visitor" "^7.22.20"
+    "@babel/helper-function-name" "^7.22.5"
+    "@babel/helper-hoist-variables" "^7.22.5"
+    "@babel/helper-split-export-declaration" "^7.22.6"
+    "@babel/parser" "^7.22.16"
+    "@babel/types" "^7.22.19"
     debug "^4.1.0"
     globals "^11.1.0"
 
-"@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==
+"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.22.15", "@babel/types@^7.22.19", "@babel/types@^7.22.5", "@babel/types@^7.4.0", "@babel/types@^7.4.4":
+  version "7.22.19"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.22.19.tgz#7425343253556916e440e662bb221a93ddb75684"
+  integrity sha512-P7LAw/LbojPzkgp5oznjE6tQEIWbp4PkkfrZDINTro9zgBRtI324/EYsiSI7lhPbpIQ+DCeR2NNmMWANGGfZsg==
   dependencies:
-    "@babel/helper-string-parser" "^7.18.10"
-    "@babel/helper-validator-identifier" "^7.18.6"
+    "@babel/helper-string-parser" "^7.22.5"
+    "@babel/helper-validator-identifier" "^7.22.19"
     to-fast-properties "^2.0.0"
 
 "@colors/colors@1.5.0":
@@ -936,10 +972,115 @@
   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==
+"@esbuild/android-arm64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz#bafb75234a5d3d1b690e7c2956a599345e84a2fd"
+  integrity sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==
+
+"@esbuild/android-arm@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.17.19.tgz#5898f7832c2298bc7d0ab53701c57beb74d78b4d"
+  integrity sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==
+
+"@esbuild/android-x64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.17.19.tgz#658368ef92067866d95fb268719f98f363d13ae1"
+  integrity sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==
+
+"@esbuild/darwin-arm64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz#584c34c5991b95d4d48d333300b1a4e2ff7be276"
+  integrity sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==
+
+"@esbuild/darwin-x64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz#7751d236dfe6ce136cce343dce69f52d76b7f6cb"
+  integrity sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==
+
+"@esbuild/freebsd-arm64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz#cacd171665dd1d500f45c167d50c6b7e539d5fd2"
+  integrity sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==
+
+"@esbuild/freebsd-x64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz#0769456eee2a08b8d925d7c00b79e861cb3162e4"
+  integrity sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==
+
+"@esbuild/linux-arm64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz#38e162ecb723862c6be1c27d6389f48960b68edb"
+  integrity sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==
+
+"@esbuild/linux-arm@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz#1a2cd399c50040184a805174a6d89097d9d1559a"
+  integrity sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==
+
+"@esbuild/linux-ia32@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz#e28c25266b036ce1cabca3c30155222841dc035a"
+  integrity sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==
+
+"@esbuild/linux-loong64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz#0f887b8bb3f90658d1a0117283e55dbd4c9dcf72"
+  integrity sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==
+
+"@esbuild/linux-mips64el@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz#f5d2a0b8047ea9a5d9f592a178ea054053a70289"
+  integrity sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==
+
+"@esbuild/linux-ppc64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz#876590e3acbd9fa7f57a2c7d86f83717dbbac8c7"
+  integrity sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==
+
+"@esbuild/linux-riscv64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz#7f49373df463cd9f41dc34f9b2262d771688bf09"
+  integrity sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==
+
+"@esbuild/linux-s390x@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz#e2afd1afcaf63afe2c7d9ceacd28ec57c77f8829"
+  integrity sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==
+
+"@esbuild/linux-x64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz#8a0e9738b1635f0c53389e515ae83826dec22aa4"
+  integrity sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==
+
+"@esbuild/netbsd-x64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz#c29fb2453c6b7ddef9a35e2c18b37bda1ae5c462"
+  integrity sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==
+
+"@esbuild/openbsd-x64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz#95e75a391403cb10297280d524d66ce04c920691"
+  integrity sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==
+
+"@esbuild/sunos-x64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz#722eaf057b83c2575937d3ffe5aeb16540da7273"
+  integrity sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==
+
+"@esbuild/win32-arm64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz#9aa9dc074399288bdcdd283443e9aeb6b9552b6f"
+  integrity sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==
+
+"@esbuild/win32-ia32@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz#95ad43c62ad62485e210f6299c7b2571e48d2b03"
+  integrity sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==
+
+"@esbuild/win32-x64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz#8cfaf2ff603e9aabb910e9c0558c26cf32744061"
+  integrity sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==
 
 "@esm-bundle/chai@^4.3.4-fix.0":
   version "4.3.4-fix.0"
@@ -948,65 +1089,56 @@
   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==
+"@jridgewell/gen-mapping@^0.3.0", "@jridgewell/gen-mapping@^0.3.2":
+  version "0.3.3"
+  resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz#7e02e6eb5df901aaedb08514203b096614024098"
+  integrity sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==
   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/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78"
-  integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==
+"@jridgewell/resolve-uri@^3.1.0":
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz#c08679063f279615a3326583ba3a90d1d82cc721"
+  integrity sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==
 
-"@jridgewell/set-array@^1.0.0", "@jridgewell/set-array@^1.0.1":
+"@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/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14":
+  version "1.4.15"
+  resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32"
+  integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==
 
-"@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==
+"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.17", "@jridgewell/trace-mapping@^0.3.9":
+  version "0.3.19"
+  resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz#f8a3249862f91be48d3127c3cfe992f79b4b8811"
+  integrity sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==
   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"
+    "@jridgewell/resolve-uri" "^3.1.0"
+    "@jridgewell/sourcemap-codec" "^1.4.14"
 
 "@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==
+  version "3.4.3"
+  resolved "https://registry.yarnpkg.com/@koa/cors/-/cors-3.4.3.tgz#d669ee6e8d6e4f0ec4a7a7b0a17e7a3ed3752ebb"
+  integrity sha512-WPXQUaAeAMVaLTEFpoq3T2O1C+FstkjJnDQqy95Ck1UdILajsRhu6mhJ8H2f4NFPRBoCNN+qywTJfq/gGki5mw==
   dependencies:
     vary "^1.1.2"
 
-"@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==
+"@lit-labs/ssr-dom-shim@^1.0.0", "@lit-labs/ssr-dom-shim@^1.1.0":
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.1.tgz#64df34e2f12e68e78ac57e571d25ec07fa460ca9"
+  integrity sha512-kXOeFbfCm4fFf2A3WwVEeQj55tMZa8c8/f9AKHMobQMkzNUfUj+antR3fRPaZJawsa1aZiP/Da3ndpZrwEe4rQ==
+
+"@lit/reactive-element@^1.0.0", "@lit/reactive-element@^1.3.0", "@lit/reactive-element@^1.6.0":
+  version "1.6.3"
+  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.6.3.tgz#25b4eece2592132845d303e091bad9b04cdcfe03"
+  integrity sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==
+  dependencies:
+    "@lit-labs/ssr-dom-shim" "^1.0.0"
 
 "@mdn/browser-compat-data@^4.0.0":
   version "4.2.1"
@@ -1035,9 +1167,9 @@
     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==
+  version "2.21.1"
+  resolved "https://registry.yarnpkg.com/@open-wc/building-utils/-/building-utils-2.21.1.tgz#3d6907c2942e76b7287a4cf82d86cadac8d1e41b"
+  integrity sha512-wCyxkvkcA7vRwXJeyrIpRhDbBrVlPGAgYKsuG9n1Pyxt2aypthtZR+1q0+wPkr6h1ZYgJnM9CWQYe72AaAXxvw==
   dependencies:
     "@babel/core" "^7.11.1"
     "@babel/plugin-syntax-dynamic-import" "^7.8.3"
@@ -1046,22 +1178,22 @@
     arrify "^2.0.1"
     browserslist "^4.16.5"
     chokidar "^3.4.3"
-    clean-css "^4.2.3"
+    clean-css "^5.3.1"
     clone "^2.1.2"
     core-js-bundle "^3.8.1"
     deepmerge "^4.2.2"
-    es-module-shims "^0.4.7"
+    es-module-shims "^1.4.1"
     html-minifier-terser "^5.1.1"
-    lru-cache "^5.1.1"
-    minimatch "^3.0.4"
-    parse5 "^5.1.1"
+    lru-cache "^6.0.0"
+    minimatch "^7.4.2"
+    parse5 "^7.1.2"
     path-is-inside "^1.0.2"
     regenerator-runtime "^0.13.7"
     resolve "^1.19.0"
     rimraf "^3.0.2"
     shady-css-scoped-element "^0.0.2"
     systemjs "^6.8.3"
-    terser "^4.6.7"
+    terser "^4.8.1"
     valid-url "^1.0.9"
     whatwg-fetch "^3.5.0"
     whatwg-url "^7.1.0"
@@ -1074,10 +1206,10 @@
     "@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/dedupe-mixin@^1.4.0":
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/@open-wc/dedupe-mixin/-/dedupe-mixin-1.4.0.tgz#b3c58f8699b197bb5e923d624c720e67c9f324d6"
+  integrity sha512-Sj7gKl1TLcDbF7B6KUhtvr+1UCxdhMbNY5KxdU5IfMFWqL8oy1ZeAcCANjoB1TL0AJTcPmcCFsCbHf8X2jGDUA==
 
 "@open-wc/karma-esm@^3.0.9":
   version "3.0.9"
@@ -1095,57 +1227,57 @@
     portfinder "^1.0.21"
     request "^2.88.0"
 
-"@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==
+"@open-wc/scoped-elements@^2.2.0":
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/@open-wc/scoped-elements/-/scoped-elements-2.2.0.tgz#4d65d7ba796c2bb76ef7934068532ca1795ea7b6"
+  integrity sha512-Qe+vWsuVHFzUkdChwlmJGuQf9cA3I+QOsSHULV/6qf6wsqLM2/32svNRH+rbBIMwiPEwzZprZlkvkqQRucYnVA==
   dependencies:
     "@lit/reactive-element" "^1.0.0"
-    "@open-wc/dedupe-mixin" "^1.3.0"
+    "@open-wc/dedupe-mixin" "^1.4.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.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:
-    "@types/chai" "^4.2.11"
-    "@web/test-runner-commands" "^0.5.7"
-
-"@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==
+"@open-wc/semantic-dom-diff@^0.19.9":
+  version "0.19.9"
+  resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.19.9.tgz#fd27659cbace40c6a59078233f4fa14a308a45b1"
+  integrity sha512-iUL0OPA6PeLQVEEJ/gsgkEiwOGgK4E1KS//zTB+u+OAh0NifNTfxDxIHQa7rEGvplaq2b2zztT2yyzOzj+MlAA==
   dependencies:
     "@types/chai" "^4.3.1"
-    "@web/test-runner-commands" "^0.6.1"
+    "@web/test-runner-commands" "^0.6.5"
 
-"@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==
+"@open-wc/semantic-dom-diff@^0.20.0":
+  version "0.20.0"
+  resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.20.0.tgz#3766aa88f67df624db0494adf82c8035216a2493"
+  integrity sha512-qGHl3nkXluXsjpLY9bSZka/cnlrybPtJMs6RjmV/OP4ID7Gcz1uNWQks05pAhptDB1R47G6PQjdwxG8dXl1zGA==
   dependencies:
-    "@open-wc/scoped-elements" "^2.1.3"
+    "@types/chai" "^4.3.1"
+    "@web/test-runner-commands" "^0.7.0"
+
+"@open-wc/testing-helpers@^2.3.0":
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-2.3.0.tgz#6ee88baaf316a6217c43e7ba536cb187d15cb6f4"
+  integrity sha512-wkDipkia/OMWq5Z1KkAgvqNLfIOCiPGrrtfoCKuQje8u7F0Bz9Un44EwBtWcCdYtLc40quWP7XFpFsW8poIfUA==
+  dependencies:
+    "@open-wc/scoped-elements" "^2.2.0"
     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==
+"@open-wc/testing@^3.2.0":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@open-wc/testing/-/testing-3.2.0.tgz#884ca348861a116829ce5657fccff11a1a9a07bd"
+  integrity sha512-9geTbFq8InbcfniPtS8KCfb5sbQ9WE6QMo1Tli8XMnfllnkZok7Az4kTRAskGQeMeQN/I2I//jE5xY/60qhrHg==
   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"
+    "@open-wc/semantic-dom-diff" "^0.20.0"
+    "@open-wc/testing-helpers" "^2.3.0"
     "@types/chai" "^4.2.11"
-    "@types/chai-dom" "^0.0.12"
+    "@types/chai-dom" "^1.11.0"
     "@types/sinon-chai" "^3.2.3"
-    chai-a11y-axe "^1.3.2"
+    chai-a11y-axe "^1.5.0"
 
 "@rollup/plugin-node-resolve@^13.0.4":
   version "13.3.0"
@@ -1180,39 +1312,53 @@
     picomatch "^2.2.2"
 
 "@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==
+  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/fake-timers@>=5", "@sinonjs/fake-timers@^9.1.2":
+"@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/commons@^3.0.0":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.0.tgz#beb434fe875d965265e04722ccfc21df7f755d72"
+  integrity sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==
+  dependencies:
+    type-detect "4.0.8"
+
+"@sinonjs/fake-timers@^10.0.2":
+  version "10.3.0"
+  resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz#55fdff1ecab9f354019129daf4df0dd4d923ea66"
+  integrity sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==
+  dependencies:
+    "@sinonjs/commons" "^3.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/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.1.1":
-  version "6.1.1"
-  resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-6.1.1.tgz#627f7f4cbdb56e6419fa2c1a3e4751ce4f6a00b1"
-  integrity sha512-cZ7rKJTLiE7u7Wi/v9Hc2fs3Ucc3jrWeMgPHbbTCeVAB2S0wOBbYlkJVeNSL04i7fdhT8wIbDq1zhC/PXTD2SA==
+  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.1"
-  resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz#8da5c6530915653f3a1f38fd5f101d8c3f8079c5"
-  integrity sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==
+  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==
 
 "@socket.io/component-emitter@~3.1.0":
   version "3.1.0"
@@ -1227,55 +1373,55 @@
     "@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==
+  version "7.0.4"
+  resolved "https://registry.yarnpkg.com/@types/babel__code-frame/-/babel__code-frame-7.0.4.tgz#0d14543f70ca91f4d2b0513a60f1eb31432c42e1"
+  integrity sha512-WBxINLlATjvmpCgBbb9tOPrKtcPfu4A/Yz2iRzmdaodfvjAS/Z0WZJClV9/EXvoC9viI3lgUs7B9Uo7G/RmMGg==
 
 "@types/babel__core@^7.1.3":
-  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==
+  version "7.20.2"
+  resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.2.tgz#215db4f4a35d710256579784a548907237728756"
+  integrity sha512-pNpr1T1xLUc2l3xJKuPtsEky3ybxN3m4fJkknfIpTCTfIZCDW57oAg+EfCgIIp2rvCe0Wn++/FfodDS4YXxBwA==
   dependencies:
-    "@babel/parser" "^7.1.0"
-    "@babel/types" "^7.0.0"
+    "@babel/parser" "^7.20.7"
+    "@babel/types" "^7.20.7"
     "@types/babel__generator" "*"
     "@types/babel__template" "*"
     "@types/babel__traverse" "*"
 
 "@types/babel__generator@*":
-  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==
+  version "7.6.5"
+  resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.5.tgz#281f4764bcbbbc51fdded0f25aa587b4ce14da95"
+  integrity sha512-h9yIuWbJKdOPLJTbmSpPzkF67e659PbQDba7ifWm5BJ8xTv+sDmS7rFmywkWOvXedGTivCdeGSIIX8WLcRTz8w==
   dependencies:
     "@babel/types" "^7.0.0"
 
 "@types/babel__template@*":
-  version "7.4.1"
-  resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.1.tgz#3d1a48fd9d6c0edfd56f2ff578daed48f36c8969"
-  integrity sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==
+  version "7.4.2"
+  resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.2.tgz#843e9f1f47c957553b0c374481dc4772921d6a6b"
+  integrity sha512-/AVzPICMhMOMYoSx9MoKpGDKdBRsIXMNByh1PXSZoa+v6ZoLa8xxtsT/uLQ/NJm0XVAWl/BvId4MlDeXJaeIZQ==
   dependencies:
     "@babel/parser" "^7.1.0"
     "@babel/types" "^7.0.0"
 
 "@types/babel__traverse@*":
-  version "7.18.1"
-  resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.18.1.tgz#ce5e2c8c272b99b7a9fd69fa39f0b4cd85028bd9"
-  integrity sha512-FSdLaZh2UxaMuLp9lixWaHq/golWTRWOnRsAXzDTDSDOQLuZb1nsdCt6pJSPWSEQt2eFZ2YVk3oYhn+1kLMeMA==
+  version "7.20.2"
+  resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.20.2.tgz#4ddf99d95cfdd946ff35d2b65c978d9c9bf2645d"
+  integrity sha512-ojlGK1Hsfce93J0+kn3H5R73elidKUaZonirN33GSmgTUMpzI/MIFfSpF3haANe3G1bEBS9/9/QEqwTzwqFsKw==
   dependencies:
-    "@babel/types" "^7.3.0"
+    "@babel/types" "^7.20.7"
 
 "@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.3"
+  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.3.tgz#fb558014374f7d9e56c8f34bab2042a3a07d25cd"
+  integrity sha512-oyl4jvAfTGX9Bt6Or4H9ni1Z447/tQuxnZsytsCaExKlmJiU8sFgnIBRzJUpKwB5eWn9HuBYlUlVA74q/yN0eQ==
   dependencies:
     "@types/connect" "*"
     "@types/node" "*"
 
 "@types/browserslist-useragent@^3.0.0":
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/@types/browserslist-useragent/-/browserslist-useragent-3.0.4.tgz#385067529bf5a59d845c98d4c56d9e8223bbf34a"
-  integrity sha512-S/AhrluMHi8EcuxxCtTDBGr8u+XvwUfLvZdARuIS2LFZ/lHoeaeJJYCozD68GKH6wm52FbIHq4WWPF/Ec6a9qA==
+  version "3.0.5"
+  resolved "https://registry.yarnpkg.com/@types/browserslist-useragent/-/browserslist-useragent-3.0.5.tgz#406d83d09688c83b42dd7f2ccf984aa41b13f243"
+  integrity sha512-CLrJk4px6W5KY/7bmSkUMpWN1qOLFxZZ9+oWvSzarxJsOnMauvY6Tblf4ePpXv/3gEZx6j2iBpH0Ow3Wp8Z8+Q==
 
 "@types/browserslist@^4.8.0":
   version "4.15.0"
@@ -1285,44 +1431,34 @@
     browserslist "*"
 
 "@types/caniuse-api@^3.0.0":
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/@types/caniuse-api/-/caniuse-api-3.0.2.tgz#684ba0c284b2a58346abf0000bd0a735ad072d75"
-  integrity sha512-YfCDMn7R59n7GFFfwjPAM0zLJQy4UvveC32rOJBmTqJJY8uSRqM4Dc7IJj8V9unA48Qy4nj5Bj3jD6Q8VZ1Seg==
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/@types/caniuse-api/-/caniuse-api-3.0.3.tgz#17899e2fa5d2443bd2576b05b7c1296b2b16cae0"
+  integrity sha512-nOcaDp0Qa1i5T0IUeW5y8jiGD2VaOj9RV5FzfV5fpMBJ0vkPIC+NV9ELKHwooxBVEN2+mI0J+v6NC7oiEXpnLQ==
 
-"@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==
+"@types/chai-dom@^1.11.0":
+  version "1.11.1"
+  resolved "https://registry.yarnpkg.com/@types/chai-dom/-/chai-dom-1.11.1.tgz#5f91fb34a612ccef177c70100c7c1b98a684d696"
+  integrity sha512-q+fs4jdKZFDhXOWBehY0jDGCp8nxVe11Ia8MxqlIsJC3Y2JU149PSBYF2li2F3uxJFSAl2Rf8XeLWonHglpcGw==
   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/chai@*", "@types/chai@^4.1.7", "@types/chai@^4.2.11", "@types/chai@^4.2.12", "@types/chai@^4.3.1":
+  version "4.3.6"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.6.tgz#7b489e8baf393d5dd1266fb203ddd4ea941259e6"
+  integrity sha512-VOVRLM1mBxIRxydiViqPcKn6MIxZytrbMpd6RJLIWKxUNr3zux8no0Oc7kJx0WAPIitgZ0gkrDS+btlqQpubpw==
 
 "@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==
+  version "6.1.1"
+  resolved "https://registry.yarnpkg.com/@types/co-body/-/co-body-6.1.1.tgz#28d253c95cfbe30c8e8c5d69d4c0dbbcffc101c2"
+  integrity sha512-I9A1k7o4m8m6YPYJIGb1JyNTLqRWtSPg1JOZPWlE19w8Su2VRgRVp/SkKftQSwoxWHGUxGbON4jltONMumC8bQ==
   dependencies:
     "@types/node" "*"
     "@types/qs" "*"
 
 "@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==
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/@types/command-line-args/-/command-line-args-5.2.1.tgz#233bd1ba687e84ecbec0388e09f9ec9ebf63c55b"
+  integrity sha512-U2OcmS2tj36Yceu+mRuPyUV0ILfau/h5onStcSCzqTENsq0sBiAp2TmaXu1k8fY4McLcPKSYl9FRzn2hx5bI+w==
 
 "@types/command-line-usage@^5.0.1":
   version "5.0.2"
@@ -1330,21 +1466,21 @@
   integrity sha512-n7RlEEJ+4x4TS7ZQddTmNSxP+zziEG0TNsMfiRIxcIVXt71ENJ9ojeXmGO3wPoTdn7pJcU2xc3CJYMktNT6DPg==
 
 "@types/connect@*":
-  version "3.4.35"
-  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1"
-  integrity sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==
+  version "3.4.36"
+  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.36.tgz#e511558c15a39cb29bd5357eebb57bd1459cd1ab"
+  integrity sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==
   dependencies:
     "@types/node" "*"
 
 "@types/content-disposition@*":
-  version "0.5.4"
-  resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.4.tgz#de48cf01c79c9f1560bcfd8ae43217ab028657f8"
-  integrity sha512-0mPF08jn9zYI0n0Q/Pnz7C4kThdSt+6LD4amsrYDDpgBfrVWa3TcCOxKX1zkGgYniGagRv8heN2cbh+CAn+uuQ==
+  version "0.5.6"
+  resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.6.tgz#0f5fa03609f308a7a1a57e0b0afe4b95f1d19740"
+  integrity sha512-GmShTb4qA9+HMPPaV2+Up8tJafgi38geFi7vL4qAM7k8BwjoelgHZqEUKJZLvughUw22h6vD/wvwN4IUCaWpDA==
 
-"@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/convert-source-map@^2.0.0":
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/@types/convert-source-map/-/convert-source-map-2.0.1.tgz#e72e8a3de9d6fe3d8e43d5c101c346de2ff6abdf"
+  integrity sha512-tm5Eb3AwhibN6ULRaad5TbNO83WoXVZLh2YRGAFH+qWkUz48l9Hu1jc+wJswB7T+ACWAG0cFnTeeQGpwedvlNw==
 
 "@types/cookie@^0.4.1":
   version "0.4.1"
@@ -1352,9 +1488,9 @@
   integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==
 
 "@types/cookies@*":
-  version "0.7.7"
-  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.7.tgz#7a92453d1d16389c05a5301eef566f34946cfd81"
-  integrity sha512-h7BcvPUogWbKCzBR2lY4oqaZbO3jXZksexYJVFvkrFeLgbZjQkU4x8pRq6eg2MHXQhY0McQdqmmsxRWlVAHooA==
+  version "0.7.8"
+  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.8.tgz#16fccd6d58513a9833c527701a90cc96d216bc18"
+  integrity sha512-y6KhF1GtsLERUpqOV+qZJrjUGzc0GE6UTa0b5Z/LZ7Nm2mKSdCXmS6Kdnl7fctPNnMSouHjxqEWI12/YqQfk5w==
   dependencies:
     "@types/connect" "*"
     "@types/express" "*"
@@ -1362,14 +1498,16 @@
     "@types/node" "*"
 
 "@types/cors@^2.8.12":
-  version "2.8.12"
-  resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080"
-  integrity sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==
+  version "2.8.14"
+  resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.14.tgz#94eeb1c95eda6a8ab54870a3bf88854512f43a92"
+  integrity sha512-RXHUvNWYICtbP6s18PnOCaqToK8y14DnLd75c6HfyKf228dxy7pHNOQkxPtvXKp/hINFMDjbYzsj63nnpPMSRQ==
+  dependencies:
+    "@types/node" "*"
 
 "@types/debounce@^1.2.0":
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.0.tgz#9ee99259f41018c640b3929e1bb32c3dcecdb192"
-  integrity sha512-bWG5wapaWgbss9E238T0R6bfo5Fh3OkeoSt245CM7JJwVwpw6MEBCbIxLq5z8KzsE3uJhzcIuQkyiZmzV3M/Dw==
+  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@0.0.39":
   version "0.0.39"
@@ -1383,41 +1521,37 @@
   dependencies:
     "@types/node" "*"
 
-"@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==
+"@types/express-serve-static-core@^4.17.33":
+  version "4.17.36"
+  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.36.tgz#baa9022119bdc05a4adfe740ffc97b5f9360e545"
+  integrity sha512-zbivROJ0ZqLAtMzgzIUC4oNqDG9iF0lSsAqpOD9kbs5xcIM3dTiyuHvBc7R8MtWBp3AAWGaovJa+wzWPjLYW7Q==
   dependencies:
     "@types/node" "*"
     "@types/qs" "*"
     "@types/range-parser" "*"
+    "@types/send" "*"
 
 "@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==
+  version "4.17.17"
+  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.17.tgz#01d5437f6ef9cfa8668e616e13c2f2ac9a491ae4"
+  integrity sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==
   dependencies:
     "@types/body-parser" "*"
-    "@types/express-serve-static-core" "^4.17.18"
+    "@types/express-serve-static-core" "^4.17.33"
     "@types/qs" "*"
     "@types/serve-static" "*"
 
 "@types/http-assert@*":
-  version "1.5.2"
-  resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.2.tgz#a7fb59a7ca366e141789a084555a633801b9af3b"
-  integrity sha512-Ddzuzv/bB2prZnJKlS1sEYhaeT50wfJjhcTTTQLjEsEZJlk3XB4Xohieyq+P4VXIzg7lrQ1Spd/PfRnBpQsdqA==
+  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.1"
-  resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-1.8.1.tgz#e81ad28a60bee0328c6d2384e029aec626f1ae67"
-  integrity sha512-e+2rjEwK6KDaNOm5Aa9wNGgyS9oSZU/4pfSMMPYNOfjvFI0WVXm29+ITRFr6aKDvvKo7uU1jV68MW4ScsfDi7Q==
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.2.tgz#a86e00bbde8950364f8e7846687259ffcd96e8c2"
+  integrity sha512-lPG6KlZs88gef6aD85z3HNkztpj7w2R7HmR3gygjfXCQmsLloWNARFkMuzKiiY8FGdh1XDpgBdrSf4aKDiA7Kg==
 
-"@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":
+"@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==
@@ -1437,14 +1571,14 @@
     "@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==
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.3.tgz#2286b16ef71d8dea74dab00902ef419a54341bfe"
+  integrity sha512-tfzBBb7OV2PbUfKbG6zRE5UbmtdLVCKT/XT364Z9ny6pXNbd9GnIB6aFYpq2A5lZ6mq9bhXgK6h5MFGNwhMmuQ==
 
 "@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==
+  version "3.2.6"
+  resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.6.tgz#17a077786d0ac5eee04c37a7d6c207b3252f6de9"
+  integrity sha512-PHiciWxH3NRyAaxUdEDE1NIZNfvhgtPlsdkjRPazHC6weqt90Jr0uLhIQs+SDwC8HQ/jnA7UQP6xOqGFB7ugWw==
   dependencies:
     "@types/koa" "*"
 
@@ -1457,17 +1591,17 @@
     "@types/node" "*"
 
 "@types/koa-etag@^3.0.0":
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/@types/koa-etag/-/koa-etag-3.0.0.tgz#d14d3dab45d5577b94bc72960631de96751341d3"
-  integrity sha512-gXQUtKGEnCy0sZLG+uE3wL4mvY1CBPcb6ECjpAoD8RGYy/8ACY1B084k8LTFPIdVcmy7GD6Y4n3up3jnupofcQ==
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@types/koa-etag/-/koa-etag-3.0.1.tgz#291025c16380dd20648c219bd2281d4721e40194"
+  integrity sha512-UkpP45FxOlwb33SPeCulTs2cIJg+tiQw/ea6vXp4JYJfMNNGUovEa/K1Id4+O2XNQe3rhCgagHMExjJfv9PhJQ==
   dependencies:
     "@types/etag" "*"
     "@types/koa" "*"
 
 "@types/koa-send@*":
-  version "4.1.3"
-  resolved "https://registry.yarnpkg.com/@types/koa-send/-/koa-send-4.1.3.tgz#17193c6472ae9e5d1b99ae8086949cc4fd69179d"
-  integrity sha512-daaTqPZlgjIJycSTNjKpHYuKhXYP30atFc1pBcy6HHqB9+vcymDgYTguPdx9tO4HMOqNyz6bz/zqpxt5eLR+VA==
+  version "4.1.4"
+  resolved "https://registry.yarnpkg.com/@types/koa-send/-/koa-send-4.1.4.tgz#c11fd792bbcf55d2c0117f975316c3f47ef2546e"
+  integrity sha512-+ttyO5T1T1cLRUtk9etg/4E7ZIplJJUANkuzYptCPysWX5LRfGHsv9YOCiB7+gkAuedjEgZrl4K02RWJ2gaJ6Q==
   dependencies:
     "@types/koa" "*"
 
@@ -1479,24 +1613,10 @@
     "@types/koa" "*"
     "@types/koa-send" "*"
 
-"@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==
-  dependencies:
-    "@types/accepts" "*"
-    "@types/content-disposition" "*"
-    "@types/cookies" "*"
-    "@types/http-assert" "*"
-    "@types/http-errors" "*"
-    "@types/keygrip" "*"
-    "@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==
+"@types/koa@*", "@types/koa@^2.0.48", "@types/koa@^2.11.6":
+  version "2.13.9"
+  resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.9.tgz#8d989ac17d7f033475fbe34c4f906c9287c2041a"
+  integrity sha512-tPX3cN1dGrMn+sjCDEiQqXH2AqlPoPd594S/8zxwUm/ZbPsQXKqHPUypr2gjCPhHUc+nDJLduhh5lXI/1olnGQ==
   dependencies:
     "@types/accepts" "*"
     "@types/content-disposition" "*"
@@ -1508,9 +1628,9 @@
     "@types/node" "*"
 
 "@types/koa__cors@^3.0.1":
-  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==
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/@types/koa__cors/-/koa__cors-3.3.1.tgz#0ec7543c4c620fd23451bfdd3e21b9a6aadedccd"
+  integrity sha512-aFGYhTFW7651KhmZZ05VG0QZJre7QxBxDj2LF1lf6GA/wSXEfKVAJxiQQWzRV4ZoMzQIO8vJBXKsUcRuvYK9qw==
   dependencies:
     "@types/koa" "*"
 
@@ -1524,6 +1644,11 @@
   resolved "https://registry.yarnpkg.com/@types/mime-types/-/mime-types-2.1.1.tgz#d9ba43490fa3a3df958759adf69396c3532cf2c1"
   integrity sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==
 
+"@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/mime@^1":
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
@@ -1546,20 +1671,15 @@
   resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-8.2.3.tgz#bbeb55fbc73f28ea6de601fbfa4613f58d785323"
   integrity sha512-ekGvFhFgrc2zYQoX4JeZPmVzZxw6Dtllga7iGHzfbYIYkAMUx/sAFP2GdFpLff+vdHXu5fl7WX9AT+TtqYcsyw==
 
-"@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/node@*", "@types/node@>=10.0.0":
+  version "20.6.3"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-20.6.3.tgz#5b763b321cd3b80f6b8dde7a37e1a77ff9358dd9"
+  integrity sha512-HksnYH4Ljr4VQgEy2lTStbCKv/P590tmPe5HqOnv9Gprffgv5WXAY+Y5Gqniu0GGqeTCUdBnzC3QSrzPkBkAMA==
 
 "@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==
+  version "6.0.3"
+  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.3.tgz#705bb349e789efa06f43f128cef51240753424cb"
+  integrity sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==
 
 "@types/path-is-inside@^1.0.0":
   version "1.0.0"
@@ -1581,9 +1701,9 @@
     "@types/node" "*"
 
 "@types/qs@*":
-  version "6.9.7"
-  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
-  integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==
+  version "6.9.8"
+  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.8.tgz#f2a7de3c107b89b441e071d5472e6b726b4adf45"
+  integrity sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==
 
 "@types/range-parser@*":
   version "1.2.4"
@@ -1604,35 +1724,44 @@
   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"
-  integrity sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==
+"@types/send@*":
+  version "0.17.1"
+  resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.1.tgz#ed4932b8a2a805f1fe362a70f4e62d0ac994e301"
+  integrity sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==
   dependencies:
     "@types/mime" "^1"
     "@types/node" "*"
 
+"@types/serve-static@*":
+  version "1.15.2"
+  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.2.tgz#3e5419ecd1e40e7405d34093f10befb43f63381a"
+  integrity sha512-J2LqtvFYCzaj8pVYKw8klQXrLLk7TBZmQ4ShlcdkELFKGwGMfevMLneMMRkMgZxotOD9wg497LpC7O8PcvAmfw==
+  dependencies:
+    "@types/http-errors" "*"
+    "@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==
+  version "3.2.9"
+  resolved "https://registry.yarnpkg.com/@types/sinon-chai/-/sinon-chai-3.2.9.tgz#71feb938574bbadcb176c68e5ff1a6014c5e69d4"
+  integrity sha512-/19t63pFYU0ikrdbXKBWj9PCdnKyTd0Qkz0X91Ta081cYsq90OxYdcWwK/dwEoDa6dtXgj2HJfmzgq+QZTHdmQ==
   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==
+  version "10.0.16"
+  resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.16.tgz#4bf10313bd9aa8eef1e50ec9f4decd3dd455b4d3"
+  integrity sha512-j2Du5SYpXZjJVJtXBokASpPRj+e2z+VUhCPHmM6WMfe3dpHu6iVKJMU6AiBcMp/XTAYnEj6Wc1trJUWwZ0QaAQ==
   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"
-  integrity sha512-BHn8Bpkapj8Wdfxvh2jWIUoaYB/9/XhsL0oOvBfRagJtKlSl9NWPcFOz2lRukI9szwGxFtYZCTejJSqsGDbdmw==
+"@types/sinon@^10.0.16":
+  version "10.0.20"
+  resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.20.tgz#f1585debf4c0d99f9938f4111e5479fb74865146"
+  integrity sha512-2APKKruFNCAZgx3daAyACGzWuJ028VVCUDk6o2rw/Z4PXT0ogwdV4KUegW0MwVs0Zu59auPXbbuBJHF12Sx1Eg==
   dependencies:
-    "@sinonjs/fake-timers" "^7.1.0"
+    "@types/sinonjs__fake-timers" "*"
 
 "@types/sinonjs__fake-timers@*":
   version "8.1.2"
@@ -1640,9 +1769,9 @@
   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==
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.4.tgz#2b38784cd16957d3782e8e2b31c03bc1d13b4d65"
+  integrity sha512-IDaobHimLQhjwsQ/NMwRVfa/yL7L/wriQPMhw1ZJall0KX6E1oxk29XMDeilW5qTIg5aoiqf5Udy8U/51aNoQQ==
 
 "@types/whatwg-url@^6.4.0":
   version "6.4.0"
@@ -1670,10 +1799,17 @@
   resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44"
   integrity sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==
 
-"@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==
+"@web/browser-logs@^0.2.2", "@web/browser-logs@^0.2.6":
+  version "0.2.6"
+  resolved "https://registry.yarnpkg.com/@web/browser-logs/-/browser-logs-0.2.6.tgz#ec936f78c7cf7b0ef9fb990c0097a3da1a756b20"
+  integrity sha512-CNjNVhd4FplRY8PPWIAt02vAowJAVcOoTNrR/NNb/o9pka7yI9qdjpWrWhEbPr2pOXonWb52AeAgdK66B8ZH7w==
+  dependencies:
+    errorstacks "^2.2.0"
+
+"@web/browser-logs@^0.3.2":
+  version "0.3.3"
+  resolved "https://registry.yarnpkg.com/@web/browser-logs/-/browser-logs-0.3.3.tgz#121e5b662db2707c4b8cd1628d86903f059f5031"
+  integrity sha512-wt8arj0x7ghXbnipgCvLR+xQ90cFg16ae23cFbInCrJvAxvyI22bAtT24W4XOXMPXwWLBVUJwBgBcXo3oKIvDw==
   dependencies:
     errorstacks "^2.2.0"
 
@@ -1684,44 +1820,20 @@
   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==
+"@web/dev-server-core@^0.4.1":
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-core/-/dev-server-core-0.4.1.tgz#803faff45281ee296d0dda02dfdd905c330db4d8"
+  integrity sha512-KdYwejXZwIZvb6tYMCqU7yBiEOPfKLQ3V9ezqqEz8DA9V9R3oQWaowckvCpFB9IxxPfS/P8/59OkdzGKQjcIUw==
   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"
+    "@web/parse5-utils" "^1.3.1"
     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"
+    isbinaryfile "^5.0.0"
     koa "^2.13.0"
     koa-etag "^4.0.0"
     koa-send "^5.0.1"
@@ -1732,53 +1844,85 @@
     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==
+"@web/dev-server-core@^0.5.1":
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-core/-/dev-server-core-0.5.2.tgz#27fe5448e587a87272b556b44ce84c6453655cdb"
+  integrity sha512-7YjWmwzM+K5fPvBCXldUIMTK4EnEufi1aWQWinQE81oW1CqzEwmyUNCtnWV9fcPA4kJC4qrpcjWNGF4YDWxuSg==
+  dependencies:
+    "@types/koa" "^2.11.6"
+    "@types/ws" "^7.4.0"
+    "@web/parse5-utils" "^2.0.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 "^5.0.0"
+    koa "^2.13.0"
+    koa-etag "^4.0.0"
+    koa-send "^5.0.1"
+    koa-static "^5.0.0"
+    lru-cache "^8.0.4"
+    mime-types "^2.1.27"
+    parse5 "^6.0.1"
+    picomatch "^2.2.2"
+    ws "^7.4.2"
+
+"@web/dev-server-esbuild@^0.3.6":
+  version "0.3.6"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-esbuild/-/dev-server-esbuild-0.3.6.tgz#838100894937443b96bfc4266c7795d27ed4afac"
+  integrity sha512-VDcZOzvmbg/z/8Q54hHqFwt9U4cacQJZxgS8YXAvyFuG85HAJ/Q55P7Tr++1NlRS8wQEos6QK2ERUWNjEVOhqQ==
   dependencies:
     "@mdn/browser-compat-data" "^4.0.0"
-    "@web/dev-server-core" "^0.3.19"
-    esbuild "^0.12 || ^0.13 || ^0.14"
+    "@web/dev-server-core" "^0.4.1"
+    esbuild "^0.16 || ^0.17"
     parse5 "^6.0.1"
-    ua-parser-js "^1.0.2"
+    ua-parser-js "^1.0.33"
 
-"@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==
+"@web/dev-server-rollup@^0.4.1":
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-rollup/-/dev-server-rollup-0.4.1.tgz#3c6606bac8e497498b5b47bf9e0c544c321b38ef"
+  integrity sha512-Ebsv7Ovd9MufeH3exvikBJ7GmrZA5OmHnOgaiHcwMJ2eQBJA5/I+/CbRjsLX97ICj/ZwZG//p2ITRz8W3UfSqg==
   dependencies:
     "@rollup/plugin-node-resolve" "^13.0.4"
-    "@web/dev-server-core" "^0.3.19"
+    "@web/dev-server-core" "^0.4.1"
     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==
+"@web/dev-server@^0.1.35":
+  version "0.1.38"
+  resolved "https://registry.yarnpkg.com/@web/dev-server/-/dev-server-0.1.38.tgz#d755092d66aeb923c546237a6c460439ea3ddd29"
+  integrity sha512-WUq7Zi8KeJ5/UZmmpZ+kzUpUlFlMP/rcreJKYg9Lxiz998KYl4G5Rv24akX0piTuqXG7r6h+zszg8V/hdzjCoA==
   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"
+    "@web/dev-server-core" "^0.4.1"
+    "@web/dev-server-rollup" "^0.4.1"
     camelcase "^6.2.0"
     command-line-args "^5.1.1"
-    command-line-usage "^6.1.1"
+    command-line-usage "^7.0.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"
+    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==
+"@web/parse5-utils@^1.3.1":
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/@web/parse5-utils/-/parse5-utils-1.3.1.tgz#6727be4d7875a9ecb96a5b3003bd271da763f8b4"
+  integrity sha512-haCgDchZrAOB9EhBJ5XqiIjBMsS/exsM5Ru7sCSyNkXVEJWskyyKuKMFk66BonnIGMPpDtqDrTUfYEis5Zi3XA==
+  dependencies:
+    "@types/parse5" "^6.0.1"
+    parse5 "^6.0.1"
+
+"@web/parse5-utils@^2.0.0":
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/@web/parse5-utils/-/parse5-utils-2.0.1.tgz#11b91417165a838954dcf228383cfd8e1bdaf914"
+  integrity sha512-FQI72BU5CXhpp7gLRskOQGGCcwvagLZnMnDwAfjrxo3pm1KOQzr8Vl+438IGpHV62xvjNdF1pjXwXcf7eekWGw==
   dependencies:
     "@types/parse5" "^6.0.1"
     parse5 "^6.0.1"
@@ -1793,48 +1937,40 @@
     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==
+"@web/test-runner-commands@^0.6.3", "@web/test-runner-commands@^0.6.5", "@web/test-runner-commands@^0.6.6":
+  version "0.6.6"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-commands/-/test-runner-commands-0.6.6.tgz#e0e8c4ce6dcd91e5b18cf2212511ee6108e31070"
+  integrity sha512-2DcK/+7f8QTicQpGFq/TmvKHDK/6Zald6rn1zqRlmj3pcH8fX6KHNVMU60Za9QgAKdorMBPfd8dJwWba5otzdw==
   dependencies:
-    "@web/test-runner-core" "^0.10.20"
+    "@web/test-runner-core" "^0.10.29"
     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==
+"@web/test-runner-commands@^0.7.0":
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-commands/-/test-runner-commands-0.7.0.tgz#c9693e4e8b05ef06a2102e03ac924bcbf7985312"
+  integrity sha512-3aXeGrkynOdJ5jgZu5ZslcWmWuPVY9/HNdWDUqPyNePG08PKmLV9Ij342ODDL6OVsxF5dvYn1312PhDqu5AQNw==
   dependencies:
-    "@web/test-runner-core" "^0.10.27"
+    "@web/test-runner-core" "^0.11.0"
     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==
+"@web/test-runner-core@^0.10.20", "@web/test-runner-core@^0.10.27", "@web/test-runner-core@^0.10.29":
+  version "0.10.29"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-core/-/test-runner-core-0.10.29.tgz#d8d909c849151cf19013d6084f89a31e400557d5"
+  integrity sha512-0/ZALYaycEWswHhpyvl5yqo0uIfCmZe8q14nGPi1dMmNiqLcHjyFGnuIiLexI224AW74ljHcHllmDlXK9FUKGA==
   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/convert-source-map" "^2.0.0"
     "@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"
+    "@web/browser-logs" "^0.2.6"
+    "@web/dev-server-core" "^0.4.1"
     chokidar "^3.4.3"
     cli-cursor "^3.1.0"
     co-body "^6.1.0"
-    convert-source-map "^1.7.0"
+    convert-source-map "^2.0.0"
     debounce "^1.2.0"
     dependency-graph "^0.11.0"
     globby "^11.0.1"
@@ -1849,30 +1985,30 @@
     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==
+"@web/test-runner-core@^0.11.0":
+  version "0.11.4"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-core/-/test-runner-core-0.11.4.tgz#590994c3fc69337e2c5411bf11c293dd061cc07a"
+  integrity sha512-E7BsKAP8FAAEsfj4viASjmuaYfOw4UlKP9IFqo4W20eVyd1nbUWU3Amq4Jksh7W/j811qh3VaFNjDfCwklQXMg==
   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/convert-source-map" "^2.0.0"
     "@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"
+    "@web/browser-logs" "^0.3.2"
+    "@web/dev-server-core" "^0.5.1"
     chokidar "^3.4.3"
     cli-cursor "^3.1.0"
     co-body "^6.1.0"
-    convert-source-map "^1.7.0"
+    convert-source-map "^2.0.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-lib-report "^3.0.1"
     istanbul-reports "^3.0.2"
     log-update "^4.0.0"
     nanocolors "^0.2.1"
@@ -1918,28 +2054,28 @@
     "@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==
+"@web/test-runner-visual-regression@^0.7.1":
+  version "0.7.1"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-visual-regression/-/test-runner-visual-regression-0.7.1.tgz#e9e153c45922ce56cf5458d20a637642fbe7e8a1"
+  integrity sha512-Pl+uyT1gmQ+vPMyJTgB8HRUyZV83PfGtopPGpsps4heGgQSP6H3XT6wvYtx0sprudFu1a8XG130XoVmK/FyRPA==
   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"
+    "@web/test-runner-commands" "^0.6.6"
+    "@web/test-runner-core" "^0.10.29"
     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==
+  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.33"
+    "@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"
@@ -1951,33 +2087,25 @@
     diff "^5.0.0"
     globby "^11.0.1"
     nanocolors "^0.2.1"
-    portfinder "^1.0.28"
+    portfinder "^1.0.32"
     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==
+  version "1.11.2"
+  resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.11.2.tgz#7539b0ad29598aa2eafee8b341059e20ac9e1006"
+  integrity sha512-vRq+GniJAYSBmTRnhCYPAPq6THYqovJ/gzGThWbgEZUQaBccndGTi1hdiUP15HzEco0I6t4RCtXyX0rsSmwgPw==
 
 "@webcomponents/webcomponentsjs@^2.4.0", "@webcomponents/webcomponentsjs@^2.5.0":
-  version "2.6.0"
-  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.6.0.tgz#7d1674c40bddf0c6dd974c44ffd34512fe7274ff"
-  integrity sha512-Moog+Smx3ORTbWwuPqoclr+uvfLnciVd6wdCaVscHPrxbmQ/IJKm3wbB7hpzJtXWjAq2l/6QMlO85aZiOdtv5Q==
+  version "2.8.0"
+  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.8.0.tgz#ab21f027594fa827c1889e8b646da7be27c7908a"
+  integrity sha512-loGD63sacRzOzSJgQnB9ZAhaQGkN7wl2Zuw7tsphI5Isa0irijrRo6EnJii/GgjGefIFO8AIO7UivzRhFaEk9w==
 
 abortcontroller-polyfill@^1.4.0:
-  version "1.7.3"
-  resolved "https://registry.yarnpkg.com/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.3.tgz#1b5b487bd6436b5b764fd52a612509702c3144b5"
-  integrity sha512-zetDJxd89y3X99Kvo4qFx8GKlt6GsvN3UcRZHwU6iFA/0KiOmhkTVhe8oRoTBiTVPZu09x3vCra47+w8Yz1+2Q==
+  version "1.7.5"
+  resolved "https://registry.yarnpkg.com/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.5.tgz#6738495f4e901fbb57b6c0611d0c75f76c485bed"
+  integrity sha512-JMJ5soJWP18htbbxJjG7bG6yuI6pRhgJ0scHHTfkUjf6wjP912xZWvM+A4sJK3gqd9E8fcPbDnOefbA9Th/FIQ==
 
-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==
-  dependencies:
-    mime-types "~2.1.24"
-    negotiator "0.6.2"
-
-accepts@~1.3.4:
+accepts@^1.3.5, 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==
@@ -1988,7 +2116,7 @@
 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=
+  integrity sha512-ltexLD/Bzwr1tDskQQFi88L4akbn8zFLIFIc00vFkH3G4hNEHruuJVcJuJTeUXLxms9dSon+cHSCmfFThnowFQ==
 
 agent-base@6:
   version "6.0.2"
@@ -2029,11 +2157,6 @@
   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"
@@ -2059,9 +2182,9 @@
   integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==
 
 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==
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
+  integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
   dependencies:
     normalize-path "^3.0.0"
     picomatch "^2.0.4"
@@ -2081,6 +2204,11 @@
   resolved "https://registry.yarnpkg.com/array-back/-/array-back-4.0.2.tgz#8004e999a6274586beeb27342168652fdb89fa1e"
   integrity sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==
 
+array-back@^6.2.2:
+  version "6.2.2"
+  resolved "https://registry.yarnpkg.com/array-back/-/array-back-6.2.2.tgz#f567d99e9af88a6d3d2f9dfcc21db6f9ba9fd157"
+  integrity sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==
+
 array-union@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d"
@@ -2126,21 +2254,14 @@
   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==
+  version "1.12.0"
+  resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.12.0.tgz#ce1c9d143389679e253b314241ea9aa5cec980d3"
+  integrity sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==
 
 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"
-  integrity sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==
-  dependencies:
-    object.assign "^4.1.0"
+  version "4.8.1"
+  resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.8.1.tgz#6948854183ee7e7eae336b9877c5bafa027998ea"
+  integrity sha512-9l850jDDPnKq48nbad8SiEelCv4OrUWrKab/cPj0GScVg6cb6NbCCt/Ulk26QEq5jP9NnGr04Bit1BHyV6r5CQ==
 
 babel-plugin-istanbul@^5.1.4:
   version "5.2.0"
@@ -2152,29 +2273,29 @@
     istanbul-lib-instrument "^3.3.0"
     test-exclude "^5.2.3"
 
-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==
+babel-plugin-polyfill-corejs2@^0.4.5:
+  version "0.4.5"
+  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.5.tgz#8097b4cb4af5b64a1d11332b6fb72ef5e64a054c"
+  integrity sha512-19hwUH5FKl49JEsvyTcoHakh6BE0wgXLLptIyKZ3PijHc/Ci521wygORCUCCred+E/twuqRyAkE02BAWPmsHOg==
   dependencies:
-    "@babel/compat-data" "^7.17.7"
-    "@babel/helper-define-polyfill-provider" "^0.3.3"
-    semver "^6.1.1"
+    "@babel/compat-data" "^7.22.6"
+    "@babel/helper-define-polyfill-provider" "^0.4.2"
+    semver "^6.3.1"
 
-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==
+babel-plugin-polyfill-corejs3@^0.8.3:
+  version "0.8.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.3.tgz#b4f719d0ad9bb8e0c23e3e630c0c8ec6dd7a1c52"
+  integrity sha512-z41XaniZL26WLrvjy7soabMXrfPWARN25PZoriDEiLMxAp50AUW3t35BGQUMg5xK3UrpVTtagIDklxYa+MhiNA==
   dependencies:
-    "@babel/helper-define-polyfill-provider" "^0.3.2"
-    core-js-compat "^3.21.0"
+    "@babel/helper-define-polyfill-provider" "^0.4.2"
+    core-js-compat "^3.31.0"
 
-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==
+babel-plugin-polyfill-regenerator@^0.5.2:
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.2.tgz#80d0f3e1098c080c8b5a65f41e9427af692dc326"
+  integrity sha512-tAlOptU0Xj34V1Y2PNTL4Y0FOJMDB6bZmoW39FeCQIhigGLkqu3Fj6uiXpxIf6Ij274ENdYx64y6Au+ZKlb1IA==
   dependencies:
-    "@babel/helper-define-polyfill-provider" "^0.3.3"
+    "@babel/helper-define-polyfill-provider" "^0.4.2"
 
 balanced-match@^1.0.0:
   version "1.0.2"
@@ -2213,20 +2334,20 @@
     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==
+  version "1.20.2"
+  resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd"
+  integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==
   dependencies:
     bytes "3.1.2"
-    content-type "~1.0.4"
+    content-type "~1.0.5"
     debug "2.6.9"
     depd "2.0.0"
     destroy "1.2.0"
     http-errors "2.0.0"
     iconv-lite "0.4.24"
     on-finished "2.4.1"
-    qs "6.10.3"
-    raw-body "2.5.1"
+    qs "6.11.0"
+    raw-body "2.5.2"
     type-is "~1.6.18"
     unpipe "1.0.0"
 
@@ -2238,7 +2359,14 @@
     balanced-match "^1.0.0"
     concat-map "0.0.1"
 
-braces@^3.0.1, braces@^3.0.2, braces@~3.0.2:
+brace-expansion@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae"
+  integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==
+  dependencies:
+    balanced-match "^1.0.0"
+
+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==
@@ -2261,15 +2389,15 @@
     useragent "^2.3.0"
     yamlparser "^0.0.2"
 
-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==
+browserslist@*, browserslist@^4.0.0, browserslist@^4.16.5, browserslist@^4.19.1, browserslist@^4.21.10, browserslist@^4.21.9, browserslist@^4.9.1:
+  version "4.21.10"
+  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.10.tgz#dbbac576628c13d3b2231332cb2ec5a46e015bb0"
+  integrity sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==
   dependencies:
-    caniuse-lite "^1.0.30001370"
-    electron-to-chromium "^1.4.202"
-    node-releases "^2.0.6"
-    update-browserslist-db "^1.0.5"
+    caniuse-lite "^1.0.30001517"
+    electron-to-chromium "^1.4.477"
+    node-releases "^2.0.13"
+    update-browserslist-db "^1.0.11"
 
 buffer-crc32@~0.2.3:
   version "0.2.13"
@@ -2294,11 +2422,6 @@
   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"
@@ -2312,7 +2435,7 @@
     mime-types "^2.1.18"
     ylru "^1.2.0"
 
-call-bind@^1.0.0, call-bind@^1.0.2:
+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==
@@ -2348,24 +2471,31 @@
     lodash.memoize "^4.1.2"
     lodash.uniq "^4.5.0"
 
-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==
+caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001033, caniuse-lite@^1.0.30001517:
+  version "1.0.30001538"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001538.tgz#9dbc6b9af1ff06b5eb12350c2012b3af56744f3f"
+  integrity sha512-HWJnhnID+0YMtGlzcp3T9drmBJUVDchPJ08tpUGFLs9CYlwWPH2uLgpHn8fND5pCgXVtnGS3H4QR9XLMHVNkHw==
 
 caseless@~0.12.0:
   version "0.12.0"
   resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
   integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==
 
-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==
+chai-a11y-axe@^1.5.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/chai-a11y-axe/-/chai-a11y-axe-1.5.0.tgz#aafa37f91f53baeafe98219768e5dee8776cf655"
+  integrity sha512-V/Vg/zJDr9aIkaHJ2KQu7lGTQQm5ZOH4u1k5iTMvIXuSVlSuUo0jcSpSqf9wUn9zl6oQXa4e4E0cqH18KOgKlQ==
   dependencies:
     axe-core "^4.3.3"
 
-chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.4.2:
+chalk-template@^0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/chalk-template/-/chalk-template-0.4.0.tgz#692c034d0ed62436b9062c1707fadcd0f753204b"
+  integrity sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==
+  dependencies:
+    chalk "^4.1.2"
+
+chalk@^2.0.1, chalk@^2.1.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==
@@ -2374,7 +2504,7 @@
     escape-string-regexp "^1.0.5"
     supports-color "^5.3.0"
 
-chalk@^4.1.0:
+chalk@^4.1.0, chalk@^4.1.2:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
   integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
@@ -2382,7 +2512,7 @@
     ansi-styles "^4.1.0"
     supports-color "^7.1.0"
 
-chokidar@3.5.3, chokidar@^3.0.0, chokidar@^3.5.1:
+chokidar@3.5.3, chokidar@^3.0.0, chokidar@^3.4.3, chokidar@^3.5.1:
   version "3.5.3"
   resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
   integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
@@ -2397,30 +2527,15 @@
   optionalDependencies:
     fsevents "~2.3.2"
 
-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==
-  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==
+  version "0.15.2"
+  resolved "https://registry.yarnpkg.com/chrome-launcher/-/chrome-launcher-0.15.2.tgz#4e6404e32200095fdce7f6a1e1004f9bd36fa5da"
+  integrity sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==
   dependencies:
     "@types/node" "*"
     escape-string-regexp "^4.0.0"
@@ -2434,6 +2549,13 @@
   dependencies:
     source-map "~0.6.0"
 
+clean-css@^5.3.1:
+  version "5.3.2"
+  resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-5.3.2.tgz#70ecc7d4d4114921f5d298349ff86a31a9975224"
+  integrity sha512-JVJbM+f3d3Q704rF4bqQ5UUyTtuJ0JRKNbTKVEeujCCBoMdkEi+V+e8oktO9qGQNSvHrFTM6JZRXrUvGR1czww==
+  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"
@@ -2453,7 +2575,7 @@
 clone@^2.1.2:
   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==
 
 co-body@^6.1.0:
   version "6.1.0"
@@ -2468,7 +2590,7 @@
 co@^4.6.0:
   version "4.6.0"
   resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
-  integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=
+  integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==
 
 color-convert@^1.9.0:
   version "1.9.3"
@@ -2487,7 +2609,7 @@
 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=
+  integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
 
 color-name@~1.1.4:
   version "1.1.4"
@@ -2501,7 +2623,7 @@
   dependencies:
     delayed-stream "~1.0.0"
 
-command-line-args@^5.0.2, command-line-args@^5.1.1:
+command-line-args@^5.0.2, command-line-args@^5.1.1, command-line-args@^5.2.1:
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.1.tgz#c44c32e437a57d7c51157696893c5909e9cec42e"
   integrity sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==
@@ -2521,6 +2643,16 @@
     table-layout "^1.0.2"
     typical "^5.2.0"
 
+command-line-usage@^7.0.0, command-line-usage@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-7.0.1.tgz#e540afef4a4f3bc501b124ffde33956309100655"
+  integrity sha512-NCyznE//MuTjwi3y84QVUGEOT+P5oto1e1Pk/jFPVdPPfsG03qpTIl3yw6etR+v73d0lXsoojRpvbru2sqePxQ==
+  dependencies:
+    array-back "^6.2.2"
+    chalk-template "^0.4.0"
+    table-layout "^3.0.0"
+    typical "^7.1.1"
+
 commander@^2.20.0:
   version "2.20.3"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
@@ -2541,7 +2673,7 @@
 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=
+  integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
 
 connect@^3.7.0:
   version "3.7.0"
@@ -2554,23 +2686,26 @@
     utils-merge "1.0.1"
 
 content-disposition@~0.5.2:
-  version "0.5.3"
-  resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd"
-  integrity sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==
+  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.1.2"
+    safe-buffer "5.2.1"
 
-content-type@^1.0.4, 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==
+content-type@^1.0.4, content-type@~1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918"
+  integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
 
 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==
-  dependencies:
-    safe-buffer "~5.1.1"
+  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@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
+  integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==
 
 cookie@~0.4.1:
   version "0.4.2"
@@ -2586,16 +2721,16 @@
     keygrip "~1.1.0"
 
 core-js-bundle@^3.6.0, core-js-bundle@^3.8.1:
-  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==
+  version "3.32.2"
+  resolved "https://registry.yarnpkg.com/core-js-bundle/-/core-js-bundle-3.32.2.tgz#3a7736797ef483ff5ced565864f7b0a09cbeded2"
+  integrity sha512-USljqWm24S8dyZdUEh8pHBxUsHcsVQaWmkZsR8e5ZHdpnGEO1XDxCZHP6/ACtgjkFQ/I/1SnTuWEBFPThMHfMQ==
 
-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==
+core-js-compat@^3.31.0:
+  version "3.32.2"
+  resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.32.2.tgz#8047d1a8b3ac4e639f0d4f66d4431aa3b16e004c"
+  integrity sha512-+GjlguTDINOijtVRUxrQOv3kfu9rl+qPNdX2LTbJ/ZyVTuxK+ksVSAGX1nHstu4hrv1En/uPTtWgq2gI5wt4AQ==
   dependencies:
-    browserslist "^4.21.3"
+    browserslist "^4.21.10"
 
 core-util-is@1.0.2:
   version "1.0.2"
@@ -2629,10 +2764,10 @@
   dependencies:
     assert-plus "^1.0.0"
 
-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==
+date-format@^4.0.14:
+  version "4.0.14"
+  resolved "https://registry.yarnpkg.com/date-format/-/date-format-4.0.14.tgz#7a8e584434fb169a521c8b7aa481f355810d9400"
+  integrity sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==
 
 debounce@^1.2.0:
   version "1.2.1"
@@ -2646,7 +2781,7 @@
   dependencies:
     ms "2.0.0"
 
-debug@4, debug@4.3.4, debug@^4.1.0, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2:
+debug@4, debug@4.3.4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, 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==
@@ -2667,13 +2802,6 @@
   dependencies:
     ms "^2.1.1"
 
-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"
-
 decamelize@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837"
@@ -2682,7 +2810,7 @@
 deep-equal@~1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
-  integrity sha1-9dJgKStmDghO/0zbyfCK0yR0SLU=
+  integrity sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==
 
 deep-extend@~0.6.0:
   version "0.6.0"
@@ -2690,23 +2818,15 @@
   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==
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
+  integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
 
 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:
-    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"
@@ -2715,7 +2835,7 @@
 delegates@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
-  integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
+  integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==
 
 depd@2.0.0, depd@^2.0.0, depd@~2.0.0:
   version "2.0.0"
@@ -2725,23 +2845,18 @@
 depd@~1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
-  integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=
+  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.2.0:
+destroy@1.2.0, destroy@^1.0.4:
   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"
@@ -2803,12 +2918,12 @@
 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=
+  integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
 
-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==
+electron-to-chromium@^1.4.477, electron-to-chromium@^1.4.67:
+  version "1.4.526"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.526.tgz#1bcda5f2b8238e497c20fcdb41af5da907a770e2"
+  integrity sha512-tjjTMjmZAx1g6COrintLTa2/jcafYKxKoiEkdQOrVdbLaHh2wCt2nsAF8ZHweezkrP+dl/VG9T5nabcYoo0U5Q==
 
 emoji-regex@^8.0.0:
   version "8.0.0"
@@ -2818,7 +2933,7 @@
 encodeurl@^1.0.2, encodeurl@~1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
-  integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
+  integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==
 
 end-of-stream@^1.1.0, end-of-stream@^1.4.1:
   version "1.4.4"
@@ -2827,15 +2942,15 @@
   dependencies:
     once "^1.4.0"
 
-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-parser@~5.2.1:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.1.tgz#9f213c77512ff1a6cc0c7a86108a7ffceb16fcfb"
+  integrity sha512-9JktcM3u18nU9N2Lz3bWeBgxVgOKpw7yhRaoxQA3FUDZzzw+9WlA6p4G4u0RixNkg14fH7EfEc/RhpurtiROTQ==
 
-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==
+engine.io@~6.5.2:
+  version "6.5.2"
+  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.5.2.tgz#769348ced9d56bd47bd83d308ec1c3375e85937c"
+  integrity sha512-IXsMcGpw/xRfjra46sVZVHiSWo/nJ/3g1337q9KNXtS6YRzbW5yIzTCb9DjhrBe7r3GZQR0I4+nq+4ODk5g/cA==
   dependencies:
     "@types/cookie" "^0.4.1"
     "@types/cors" "^2.8.12"
@@ -2845,14 +2960,19 @@
     cookie "~0.4.1"
     cors "~2.8.5"
     debug "~4.3.1"
-    engine.io-parser "~5.0.3"
-    ws "~8.2.3"
+    engine.io-parser "~5.2.1"
+    ws "~8.11.0"
 
 ent@~2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.0.tgz#e964219325a21d05f44466a2f686ed6ce5f5dd1d"
   integrity sha512-GHrMyVZQWvTIdDtpiEXdHZnFQKzeO09apj8Cbl4pKWy4i0Oprcq17usfDt5aO63swf0JOeMWjWQE/LzgSRuWpA==
 
+entities@^4.4.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
+  integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
+
 error-ex@^1.3.1:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
@@ -2861,9 +2981,9 @@
     is-arrayish "^0.2.1"
 
 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==
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/errorstacks/-/errorstacks-2.4.0.tgz#2155674dd9e741aacda3f3b8b967d9c40a4a0baf"
+  integrity sha512-5ecWhU5gt0a5G05nmQcgCxP5HperSMxLDzvWlT5U+ZSKkuDK0rJ3dbCQny6/vSCIXjwrhwSecXBbw1alr295hQ==
 
 es-dev-server@^1.57.8:
   version "1.60.2"
@@ -2941,147 +3061,48 @@
   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==
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.3.1.tgz#c1b0dd5ada807a3b3155315911f364dc4e909db1"
+  integrity sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==
 
-es-module-shims@^0.4.6, es-module-shims@^0.4.7:
+es-module-shims@^0.4.6:
   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==
+es-module-shims@^1.4.1:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/es-module-shims/-/es-module-shims-1.8.0.tgz#c0c8e7f5a0d041998b9410879619ab745cb76a3a"
+  integrity sha512-5l/AqgnWvYFF38qkK8VNoQ8BL3LkJ8bAJuxhOKA/JqoLC4bcaeJeLwMkhEcrDsf5IUCDdwZ6eEG40+Xuh/APcQ==
 
-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==
+"esbuild@^0.16 || ^0.17":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.17.19.tgz#087a727e98299f0462a3d0bcdd9cd7ff100bd955"
+  integrity sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==
   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"
+    "@esbuild/android-arm" "0.17.19"
+    "@esbuild/android-arm64" "0.17.19"
+    "@esbuild/android-x64" "0.17.19"
+    "@esbuild/darwin-arm64" "0.17.19"
+    "@esbuild/darwin-x64" "0.17.19"
+    "@esbuild/freebsd-arm64" "0.17.19"
+    "@esbuild/freebsd-x64" "0.17.19"
+    "@esbuild/linux-arm" "0.17.19"
+    "@esbuild/linux-arm64" "0.17.19"
+    "@esbuild/linux-ia32" "0.17.19"
+    "@esbuild/linux-loong64" "0.17.19"
+    "@esbuild/linux-mips64el" "0.17.19"
+    "@esbuild/linux-ppc64" "0.17.19"
+    "@esbuild/linux-riscv64" "0.17.19"
+    "@esbuild/linux-s390x" "0.17.19"
+    "@esbuild/linux-x64" "0.17.19"
+    "@esbuild/netbsd-x64" "0.17.19"
+    "@esbuild/openbsd-x64" "0.17.19"
+    "@esbuild/sunos-x64" "0.17.19"
+    "@esbuild/win32-arm64" "0.17.19"
+    "@esbuild/win32-ia32" "0.17.19"
+    "@esbuild/win32-x64" "0.17.19"
 
 escalade@^3.1.1:
   version "3.1.1"
@@ -3091,7 +3112,7 @@
 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=
+  integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
 
 escape-string-regexp@4.0.0, escape-string-regexp@^4.0.0:
   version "4.0.0"
@@ -3101,7 +3122,7 @@
 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==
 
 estree-walker@^1.0.1:
   version "1.0.1"
@@ -3154,10 +3175,10 @@
   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==
+fast-glob@^3.2.9:
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.1.tgz#784b4e897340f3dbbef17413b3f11acf03c874c4"
+  integrity sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==
   dependencies:
     "@nodelib/fs.stat" "^2.0.2"
     "@nodelib/fs.walk" "^1.2.3"
@@ -3171,9 +3192,9 @@
   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==
+  version "1.15.0"
+  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.15.0.tgz#d04d07c6a2a68fe4599fea8d2e103a937fae6b3a"
+  integrity sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==
   dependencies:
     reusify "^1.0.4"
 
@@ -3239,15 +3260,15 @@
   resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241"
   integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==
 
-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==
+flatted@^3.2.7:
+  version "3.2.9"
+  resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.9.tgz#7eb4c67ca1ba34232ca9d2d93e9886e611ad7daf"
+  integrity sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==
 
 follow-redirects@^1.0.0:
-  version "1.15.2"
-  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"
-  integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==
+  version "1.15.3"
+  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.3.tgz#fe2f3ef2690afce7e82ed0b44db08165b207123a"
+  integrity sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==
 
 forever-agent@~0.6.1:
   version "0.6.1"
@@ -3266,7 +3287,7 @@
 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-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==
 
 fs-constants@^1.0.0:
   version "1.0.0"
@@ -3285,13 +3306,18 @@
 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=
+  integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
 
-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==
 
+fsevents@~2.3.2:
+  version "2.3.3"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
+  integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
+
 function-bind@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
@@ -3308,21 +3334,13 @@
   integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
 
 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==
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.1.tgz#d295644fed4505fc9cde952c37ee12b477a83d82"
+  integrity sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==
   dependencies:
     function-bind "^1.1.1"
     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-proto "^1.0.1"
     has-symbols "^1.0.3"
 
 get-stream@^5.1.0:
@@ -3363,19 +3381,7 @@
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
-glob@^7.1.3:
-  version "7.1.7"
-  resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90"
-  integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==
-  dependencies:
-    fs.realpath "^1.0.0"
-    inflight "^1.0.4"
-    inherits "2"
-    minimatch "^3.0.4"
-    once "^1.3.0"
-    path-is-absolute "^1.0.0"
-
-glob@^7.1.7:
+glob@^7.1.3, 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==
@@ -3393,21 +3399,21 @@
   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==
+  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"
 
 graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.6:
-  version "4.2.10"
-  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c"
-  integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==
+  version "4.2.11"
+  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
+  integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
 
 growl@1.10.5:
   version "1.10.5"
@@ -3430,26 +3436,19 @@
 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=
+  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-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-proto@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.1.tgz#1885c1305538958aff469fef37937c22795408e0"
+  integrity sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==
 
-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:
+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==
@@ -3497,23 +3496,12 @@
     terser "^4.6.3"
 
 http-assert@^1.3.0:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/http-assert/-/http-assert-1.4.1.tgz#c5f725d677aa7e873ef736199b89686cceb37878"
-  integrity sha512-rdw7q6GTlibqVVbXr0CKelfV5iY8G2HqEUkhSk297BMbSpSL8crXC+9rjKoMcZZEsksX30le6f/4ul4E28gegw==
+  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.7.2"
-
-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.4"
-    setprototypeof "1.1.1"
-    statuses ">= 1.5.0 < 2"
-    toidentifier "1.0.0"
+    http-errors "~1.8.0"
 
 http-errors@2.0.0:
   version "2.0.0"
@@ -3526,21 +3514,21 @@
     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"
-  integrity sha512-4I8r0C5JDhT5VkvI47QktDW75rNlGVsUf/8hzjCC/wkWI/jdTRmBb9aI7erSG82r1bjKY3F6k28WnsVxB1C73A==
+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.0"
+    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 sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=
+  integrity sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==
   dependencies:
     depd "~1.1.2"
     inherits "2.0.3"
@@ -3585,20 +3573,20 @@
   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==
+ignore@^5.2.0:
+  version "5.2.4"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324"
+  integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==
 
 inflation@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/inflation/-/inflation-2.0.0.tgz#8b417e47c28f925a45133d914ca1fd389107f30f"
-  integrity sha1-i0F+R8KPklpFEz2RTKH9OJEH8w8=
+  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 sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=
+  integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==
   dependencies:
     once "^1.3.0"
     wrappy "1"
@@ -3611,7 +3599,7 @@
 inherits@2.0.3:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
-  integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
+  integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==
 
 intersection-observer@^0.7.0:
   version "0.7.0"
@@ -3619,9 +3607,9 @@
   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=
+  version "1.1.8"
+  resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.8.tgz#ae05948f6b075435ed3307acce04629da8cdbf48"
+  integrity sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg==
 
 is-arrayish@^0.2.1:
   version "0.2.1"
@@ -3636,23 +3624,16 @@
     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==
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.1.tgz#f03271717d8654cfcaf07ab0463faa3571581169"
+  integrity sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==
   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"
-  integrity sha512-TXCMSDsEHMEEZ6eCA8rwRDbLu55MRGmrctljsBX/2v1d9/GzqHOxW5c5oPSgrUt2vBFXebu9rGqckXGPWOlYpg==
-  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==
+is-core-module@^2.13.0:
+  version "2.13.0"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.0.tgz#bb52aa6e2cbd49a30c2ba68c42bf3435ba6072db"
+  integrity sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==
   dependencies:
     has "^1.0.3"
 
@@ -3664,7 +3645,7 @@
 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=
+  integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
 
 is-fullwidth-code-point@^3.0.0:
   version "3.0.0"
@@ -3679,16 +3660,16 @@
     has-tostringtag "^1.0.0"
 
 is-glob@^4.0.1, is-glob@~4.0.1:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc"
-  integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==
+  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 sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=
+  integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==
 
 is-number@^7.0.0:
   version "7.0.0"
@@ -3725,17 +3706,17 @@
 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=
+  integrity sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==
 
 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==
+isbinaryfile@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-5.0.0.tgz#034b7e54989dab8986598cbcea41f66663c65234"
+  integrity sha512-UDdnyGvMajJUWCkib7Cei/dvyJrrvo4FIrsvSFWdPpXSUorzXrDJ0S+X5Q4ZlasfPjca4yqCNNsjbCeiy8FFeg==
 
 isexe@^2.0.0:
   version "2.0.0"
@@ -3770,19 +3751,19 @@
     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==
+istanbul-lib-report@^3.0.0, istanbul-lib-report@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d"
+  integrity sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==
   dependencies:
     istanbul-lib-coverage "^3.0.0"
-    make-dir "^3.0.0"
+    make-dir "^4.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==
+  version "3.1.6"
+  resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.6.tgz#2544bcab4768154281a2f0870471902704ccaa1a"
+  integrity sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==
   dependencies:
     html-escaper "^2.0.0"
     istanbul-lib-report "^3.0.0"
@@ -3834,10 +3815,10 @@
   resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
   integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==
 
-json5@^2.2.1:
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c"
-  integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==
+json5@^2.2.3:
+  version "2.2.3"
+  resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
+  integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
 
 jsonfile@^4.0.0:
   version "4.0.0"
@@ -3861,10 +3842,10 @@
   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.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==
+karma-chrome-launcher@^3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-3.2.0.tgz#eb9c95024f2d6dfbb3748d3415ac9b381906b9a9"
+  integrity sha512-rE9RkUPI7I9mAxByQWkGJFXfFD6lE4gC5nPuZdobf/QdTEJI6EU4yIay/cfU/xV4ZxlM5JiTv7zWYgA64NpS5Q==
   dependencies:
     which "^1.2.1"
 
@@ -3884,10 +3865,10 @@
   dependencies:
     minimist "^1.2.3"
 
-karma@^6.3.20:
-  version "6.4.0"
-  resolved "https://registry.yarnpkg.com/karma/-/karma-6.4.0.tgz#82652dfecdd853ec227b74ed718a997028a99508"
-  integrity sha512-s8m7z0IF5g/bS5ONT7wsOavhW4i4aFkzD4u4wgzAQWT4HGUeWI3i21cK2Yz6jndMAeHETp5XuNsRoyGJZXVd4w==
+karma@^6.4.2:
+  version "6.4.2"
+  resolved "https://registry.yarnpkg.com/karma/-/karma-6.4.2.tgz#a983f874cee6f35990c4b2dcc3d274653714de8e"
+  integrity sha512-C6SU/53LB31BEgRg+omznBEMY4SjHU3ricV6zBcAe1EeILKkeScr+fZXtaI5WyDbkVowJxxAI6h73NcFPmXolQ==
   dependencies:
     "@colors/colors" "1.5.0"
     body-parser "^1.19.0"
@@ -3982,9 +3963,9 @@
     koa-send "^5.0.0"
 
 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==
+  version "2.14.2"
+  resolved "https://registry.yarnpkg.com/koa/-/koa-2.14.2.tgz#a57f925c03931c2b4d94b19d2ebf76d3244863fc"
+  integrity sha512-VFI2bpJaodz6P7x2uyLiX6RLYpZmOJqNmoCst/Yyd7hQlszyPwG/I9CQJ63nOtKSxpt5M7NH67V6nJL2BwCl7g==
   dependencies:
     accepts "^1.3.5"
     cache-content-type "^1.0.0"
@@ -4011,36 +3992,37 @@
     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==
+  version "1.4.2"
+  resolved "https://registry.yarnpkg.com/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz#aef90f9e97cd81db367c7634292ee22079280aaa"
+  integrity sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==
   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==
+lit-element@^3.3.0:
+  version "3.3.3"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.3.3.tgz#10bc19702b96ef5416cf7a70177255bfb17b3209"
+  integrity sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==
   dependencies:
-    "@lit/reactive-element" "^1.0.0"
-    lit-html "^2.0.0"
+    "@lit-labs/ssr-dom-shim" "^1.1.0"
+    "@lit/reactive-element" "^1.3.0"
+    lit-html "^2.8.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==
+lit-html@^2.0.0, lit-html@^2.8.0:
+  version "2.8.0"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.8.0.tgz#96456a4bb4ee717b9a7d2f94562a16509d39bffa"
+  integrity sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==
   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==
+  version "2.8.0"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-2.8.0.tgz#4d838ae03059bf9cafa06e5c61d8acc0081e974e"
+  integrity sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==
   dependencies:
-    "@lit/reactive-element" "^1.0.0"
-    lit-element "^3.0.0"
-    lit-html "^2.0.0"
+    "@lit/reactive-element" "^1.6.0"
+    lit-element "^3.3.0"
+    lit-html "^2.8.0"
 
 load-json-file@^4.0.0:
   version "4.0.0"
@@ -4074,10 +4056,15 @@
   dependencies:
     p-locate "^5.0.0"
 
+lodash.assignwith@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/lodash.assignwith/-/lodash.assignwith-4.2.0.tgz#127a97f02adc41751a954d24b0de17e100e038eb"
+  integrity sha512-ZznplvbvtjK2gMvnQ1BR/zqPFZmS6jbK4p+6Up4xcRYA7yMIwxHCfbTcrYxXKzzqLsQ05eJPVznEW3tuwV7k1g==
+
 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=
+  integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==
 
 lodash.debounce@^4.0.8:
   version "4.0.8"
@@ -4087,7 +4074,7 @@
 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=
+  integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==
 
 lodash.memoize@^4.1.2:
   version "4.1.2"
@@ -4135,15 +4122,15 @@
     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==
+  version "6.9.1"
+  resolved "https://registry.yarnpkg.com/log4js/-/log4js-6.9.1.tgz#aba5a3ff4e7872ae34f8b4c533706753709e38b6"
+  integrity sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==
   dependencies:
-    date-format "^4.0.13"
+    date-format "^4.0.14"
     debug "^4.3.4"
-    flatted "^3.2.6"
+    flatted "^3.2.7"
     rfdc "^1.3.0"
-    streamroller "^3.1.2"
+    streamroller "^3.1.5"
 
 lower-case@^2.0.2:
   version "2.0.2"
@@ -4174,12 +4161,17 @@
   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==
+lru-cache@^8.0.4:
+  version "8.0.5"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-8.0.5.tgz#983fe337f3e176667f8e567cfcce7cb064ea214e"
+  integrity sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==
+
+make-dir@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e"
+  integrity sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==
   dependencies:
-    semver "^6.0.0"
+    semver "^7.5.3"
 
 marky@^1.2.2:
   version "1.2.5"
@@ -4189,45 +4181,33 @@
 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=
+  integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==
 
-merge2@^1.3.0:
+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.4"
-  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.4.tgz#896d519dfe9db25fce94ceb7a500919bf881ebf9"
-  integrity sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==
+  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.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==
+    braces "^3.0.2"
+    picomatch "^2.3.1"
 
 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:
+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-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==
-  dependencies:
-    mime-db "1.49.0"
-
 mime@^2.5.2:
   version "2.6.0"
   resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367"
@@ -4245,24 +4225,24 @@
   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"
-
-minimatch@^3.1.1:
+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"
 
+minimatch@^7.4.2:
+  version "7.4.6"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-7.4.6.tgz#845d6f254d8f4a5e4fd6baf44d5f10c8448365fb"
+  integrity sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==
+  dependencies:
+    brace-expansion "^2.0.1"
+
 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==
+  version "1.2.8"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
+  integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
 
 mkdirp-classic@^0.5.2:
   version "0.5.3"
@@ -4314,7 +4294,7 @@
 ms@2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
-  integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=
+  integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==
 
 ms@2.1.2:
   version "2.1.2"
@@ -4346,14 +4326,9 @@
   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==
+  version "3.3.6"
+  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"
+  integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==
 
 negotiator@0.6.3:
   version "0.6.3"
@@ -4361,12 +4336,12 @@
   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==
+  version "5.1.4"
+  resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.4.tgz#491ce7e7307d4ec546f5a659b2efe94a18b4bbc0"
+  integrity sha512-8+Ib8rRJ4L0o3kfmyVCL7gzrohyDe0cMFTBa2d364yIrEGMEoetznKJx899YxjybU6bL9SQkYPSBBs1gyYs8Xg==
   dependencies:
-    "@sinonjs/commons" "^1.8.3"
-    "@sinonjs/fake-timers" ">=5"
+    "@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"
@@ -4379,17 +4354,24 @@
     lower-case "^2.0.2"
     tslib "^2.0.3"
 
-node-fetch@2.6.7, node-fetch@^2.6.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"
 
-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==
+node-fetch@^2.6.0:
+  version "2.7.0"
+  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
+  integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
+  dependencies:
+    whatwg-url "^5.0.0"
+
+node-releases@^2.0.13:
+  version "2.0.13"
+  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.13.tgz#d5ed1627c23e3461e819b02e57b75e4899b1c81d"
+  integrity sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==
 
 normalize-package-data@^2.3.2:
   version "2.5.0"
@@ -4417,43 +4399,28 @@
   integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
 
 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==
+  version "1.12.3"
+  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9"
+  integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==
 
-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.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.2"
-    define-properties "^1.1.4"
-    has-symbols "^1.0.3"
-    object-keys "^1.1.1"
-
-on-finished@2.4.1:
+on-finished@2.4.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"
 
-on-finished@^2.3.0, 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=
+  integrity sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==
   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 sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
+  integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==
   dependencies:
     wrappy "1"
 
@@ -4467,7 +4434,7 @@
 only@~0.0.2:
   version "0.0.2"
   resolved "https://registry.yarnpkg.com/only/-/only-0.0.2.tgz#2afde84d03e50b9a8edc444e30610a70295edfb4"
-  integrity sha1-Kv3oTQPlC5qO3EROMGEKcCle37Q=
+  integrity sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==
 
 open@^7.0.3:
   version "7.4.2"
@@ -4478,9 +4445,9 @@
     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==
+  version "8.4.2"
+  resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9"
+  integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==
   dependencies:
     define-lazy-prop "^2.0.0"
     is-docker "^2.1.1"
@@ -4557,6 +4524,13 @@
   resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
   integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==
 
+parse5@^7.1.2:
+  version "7.1.2"
+  resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32"
+  integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==
+  dependencies:
+    entities "^4.4.0"
+
 parseurl@^1.3.2, parseurl@~1.3.3:
   version "1.3.3"
   resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
@@ -4583,14 +4557,14 @@
 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=
+  integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==
 
 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 sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==
 
-path-parse@^1.0.6, path-parse@^1.0.7:
+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==
@@ -4629,10 +4603,10 @@
   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==
+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==
 
 pify@^3.0.0:
   version "3.0.0"
@@ -4653,17 +4627,19 @@
   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-core@1.38.0:
+  version "1.38.0"
+  resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.38.0.tgz#cb8e135da1c0b1918b070642372040ed9aa7009a"
+  integrity sha512-f8z1y8J9zvmHoEhKgspmCvOExF2XdcxMW8jNRuX4vkQFrzV4MlZ55iwb5QeyiFQgOFCUolXiRHgpjSEnqvO48g==
 
 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==
+  version "1.38.0"
+  resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.38.0.tgz#0ee19d38512b7b1f961c0eb44008a6fed373d206"
+  integrity sha512-fJGw+HO0YY+fU/F1N57DMO+TmXHTrmr905J05zwAQE9xkuwP/QLDk63rVhmyxh03dYnEhnRbsdbH9B0UVVRB3A==
   dependencies:
-    playwright-core "1.27.1"
+    playwright-core "1.38.0"
+  optionalDependencies:
+    fsevents "2.3.2"
 
 pngjs@^6.0.0:
   version "6.0.0"
@@ -4691,7 +4667,7 @@
     terser "^4.6.7"
     whatwg-fetch "^3.0.0"
 
-portfinder@^1.0.21, portfinder@^1.0.28:
+portfinder@^1.0.21, portfinder@^1.0.32:
   version "1.0.32"
   resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.32.tgz#2fe1b9e58389712429dc2bea5beb2146146c7f81"
   integrity sha512-on2ZJVVDXRADWE6jnQaX0ioEylzgBpQk8r55NE4wjXW1ZxO+BgDlY6DXwj20i0V8eB4SenDQ00WEaxfiIQPcxg==
@@ -4729,9 +4705,9 @@
     once "^1.3.1"
 
 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==
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f"
+  integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==
 
 puppeteer-core@^13.1.3:
   version "13.7.0"
@@ -4756,17 +4732,17 @@
   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.10.3:
-  version "6.10.3"
-  resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.3.tgz#d6cde1b2ffca87b5aa57889816c5f81535e22e8e"
-  integrity sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==
+qs@6.11.0:
+  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"
 
 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==
+  version "6.11.2"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.2.tgz#64bea51f12c1f5da1bc01496f48ffcff7c69d7d9"
+  integrity sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==
   dependencies:
     side-channel "^1.0.4"
 
@@ -4792,26 +4768,16 @@
   resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
   integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
 
-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==
+raw-body@2.5.2, raw-body@^2.3.3:
+  version "2.5.2"
+  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a"
+  integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==
   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.3"
-    iconv-lite "0.4.24"
-    unpipe "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"
@@ -4830,9 +4796,9 @@
     path-type "^3.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==
+  version "3.6.2"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967"
+  integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==
   dependencies:
     inherits "^2.0.3"
     string_decoder "^1.1.1"
@@ -4850,10 +4816,10 @@
   resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-2.0.0.tgz#734fd84e65f375d7ca4465c69798c25c9d10ae27"
   integrity sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==
 
-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==
+regenerate-unicode-properties@^10.1.0:
+  version "10.1.1"
+  resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.1.tgz#6b0e05489d9076b04c436f318d9b067bba459480"
+  integrity sha512-X007RyZLsCJVVrjgEFVpLUTZwyOZk3oiL75ZcuYjlIWd6rNJtOjkBwQc5AsRrpbKVkxN6sklw/k/9m2jJYOf8Q==
   dependencies:
     regenerate "^1.4.2"
 
@@ -4862,39 +4828,39 @@
   resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a"
   integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==
 
-regenerator-runtime@^0.13.3, regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.7:
-  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-runtime@^0.13.3, regenerator-runtime@^0.13.7:
+  version "0.13.11"
+  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9"
+  integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==
 
-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==
+regenerator-runtime@^0.14.0:
+  version "0.14.0"
+  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz#5e19d68eb12d486f797e15a3c6a918f7cec5eb45"
+  integrity sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==
+
+regenerator-transform@^0.15.2:
+  version "0.15.2"
+  resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.2.tgz#5bbae58b522098ebdf09bca2f83838929001c7a4"
+  integrity sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==
   dependencies:
     "@babel/runtime" "^7.8.4"
 
-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==
+regexpu-core@^5.3.1:
+  version "5.3.2"
+  resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-5.3.2.tgz#11a2b06884f3527aec3e93dbbf4a3b958a95546b"
+  integrity sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==
   dependencies:
+    "@babel/regjsgen" "^0.8.0"
     regenerate "^1.4.2"
-    regenerate-unicode-properties "^10.0.1"
-    regjsgen "^0.6.0"
-    regjsparser "^0.8.2"
+    regenerate-unicode-properties "^10.1.0"
+    regjsparser "^0.9.1"
     unicode-match-property-ecmascript "^2.0.0"
-    unicode-match-property-value-ecmascript "^2.0.0"
+    unicode-match-property-value-ecmascript "^2.1.0"
 
-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.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==
+regjsparser@^0.9.1:
+  version "0.9.1"
+  resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.9.1.tgz#272d05aa10c7c1f67095b1ff0addae8442fc5709"
+  integrity sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==
   dependencies:
     jsesc "~0.5.0"
 
@@ -4952,28 +4918,20 @@
 resolve-path@^1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/resolve-path/-/resolve-path-1.4.0.tgz#c4bda9f5efb2fce65247873ab36bb4d834fe16f7"
-  integrity sha1-xL2p9e+y/OZSR4c6s2u02DT+Fvc=
+  integrity sha512-i1xevIst/Qa+nA9olDxLWnLk8YZbi8R/7JPbCMcgyWaFR6bKWaexgJgEB5oc2PKMjYdrHynyz0NY+if+H98t1w==
   dependencies:
     http-errors "~1.6.2"
     path-is-absolute "1.0.1"
 
-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==
+resolve@^1.10.0, resolve@^1.14.2, resolve@^1.19.0:
+  version "1.22.6"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.6.tgz#dd209739eca3aef739c626fea1b4f3c506195362"
+  integrity sha512-njhxM7mV12JfufShqGy3Rz8j11RPdLy4xi15UurGJeoHLfJpVXKdh3ueuOqbYUcDZnffr6X739JBo5LzyahEsw==
   dependencies:
-    is-core-module "^2.9.0"
+    is-core-module "^2.13.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==
-  dependencies:
-    is-core-module "^2.2.0"
-    path-parse "^1.0.6"
-
 restore-cursor@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e"
@@ -5000,9 +4958,9 @@
     glob "^7.1.3"
 
 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==
+  version "2.79.1"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7"
+  integrity sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==
   optionalDependencies:
     fsevents "~2.3.2"
 
@@ -5013,12 +4971,7 @@
   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.2.0:
+safe-buffer@5.2.1, 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==
@@ -5029,19 +4982,19 @@
   integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
 
 "semver@2 || 3 || 4 || 5":
-  version "5.7.1"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
-  integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
+  version "5.7.2"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8"
+  integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==
 
-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@^6.0.0, semver@^6.3.1:
+  version "6.3.1"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
+  integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
 
-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==
+semver@^7.3.4, semver@^7.3.5, semver@^7.5.3:
+  version "7.5.4"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
+  integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
   dependencies:
     lru-cache "^6.0.0"
 
@@ -5057,11 +5010,6 @@
   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==
-
 setprototypeof@1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
@@ -5082,11 +5030,11 @@
     object-inspect "^1.9.0"
 
 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==
+  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:
+sinon@^13.0.2:
   version "13.0.2"
   resolved "https://registry.yarnpkg.com/sinon/-/sinon-13.0.2.tgz#c6a8ddd655dc1415bbdc5ebf0e5b287806850c3a"
   integrity sha512-KvOrztAVqzSJWMDoxM4vM+GPys1df2VBoXm+YciyB/OLMamfS3VXh3oGh5WtrAGSzrgczNWFFY22oKb7Fi5eeA==
@@ -5112,32 +5060,35 @@
     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-adapter@~2.5.2:
+  version "2.5.2"
+  resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.5.2.tgz#5de9477c9182fdc171cd8c8364b9a8894ec75d12"
+  integrity sha512-87C3LO/NOMc+eMcpcxUBebGjkpMDkNBS9tf7KJqcDsmL936EChtVva71Dw2q4tQcuVC+hAUy4an2NO/sYXmwRA==
+  dependencies:
+    ws "~8.11.0"
 
-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==
+socket.io-parser@~4.2.4:
+  version "4.2.4"
+  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.4.tgz#c806966cf7270601e47469ddeec30fbdfda44c83"
+  integrity sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==
   dependencies:
     "@socket.io/component-emitter" "~3.1.0"
     debug "~4.3.1"
 
 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==
+  version "4.7.2"
+  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.7.2.tgz#22557d76c3f3ca48f82e73d68b7add36a22df002"
+  integrity sha512-bvKVS29/I5fl2FGLNHuXlQaUH/BlzX1IN6S+NKLNZpBsPZIDH+90eQmCs2Railn4YUiww4SzUedJ6+uzwFnKLw==
   dependencies:
     accepts "~1.3.4"
     base64id "~2.0.0"
+    cors "~2.8.5"
     debug "~4.3.2"
-    engine.io "~6.2.0"
-    socket.io-adapter "~2.4.0"
-    socket.io-parser "~4.2.0"
+    engine.io "~6.5.2"
+    socket.io-adapter "~2.5.2"
+    socket.io-parser "~4.2.4"
 
-source-map-support@^0.5.19, source-map-support@~0.5.12:
+source-map-support@^0.5.21, source-map-support@~0.5.12:
   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==
@@ -5151,14 +5102,14 @@
   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==
+  version "0.7.4"
+  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656"
+  integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==
 
 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==
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.2.0.tgz#4f5ab0668f0059e34f9c00dce331784a12de4e9c"
+  integrity sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==
   dependencies:
     spdx-expression-parse "^3.0.0"
     spdx-license-ids "^3.0.0"
@@ -5177,9 +5128,9 @@
     spdx-license-ids "^3.0.0"
 
 spdx-license-ids@^3.0.0:
-  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==
+  version "3.0.15"
+  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.15.tgz#142460aabaca062bc7cd4cc87b7d50725ed6a4ba"
+  integrity sha512-lpT8hSQp9jAKp9mhtBU4Xjon8LPGBvLIuBiSVhMEtmLecTh2mO0tlqrAMp47tBXzMr13NJMQ2lf7RpQGLJ3HsQ==
 
 sshpk@^1.7.0:
   version "1.17.0"
@@ -5204,27 +5155,23 @@
 "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=
+  integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==
 
-streamroller@^3.1.2:
-  version "3.1.2"
-  resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-3.1.2.tgz#abd444560768b340f696307cf84d3f46e86c0e63"
-  integrity sha512-wZswqzbgGGsXYIrBYhOE0yP+nQ6XRk7xDcYwuQAGTYXdyAUmvgVFE0YU1g5pvQT0m7GBaQfYcSnlHbapuK0H0A==
+stream-read-all@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/stream-read-all/-/stream-read-all-3.0.1.tgz#60762ae45e61d93ba0978cda7f3913790052ad96"
+  integrity sha512-EWZT9XOceBPlVJRrYcykW8jyRSZYbkb/0ZK36uLEmoWVO5gxBOnntNTseNzfREsqxqdfEGQrD8SXQ3QWbBmq8A==
+
+streamroller@^3.1.5:
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-3.1.5.tgz#1263182329a45def1ffaef58d31b15d13d2ee7ff"
+  integrity sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==
   dependencies:
-    date-format "^4.0.13"
+    date-format "^4.0.14"
     debug "^4.3.4"
     fs-extra "^8.1.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-width@^4.2.0:
+string-width@^4.1.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==
@@ -5254,14 +5201,7 @@
   dependencies:
     ansi-regex "^4.1.0"
 
-strip-ansi@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532"
-  integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==
-  dependencies:
-    ansi-regex "^5.0.0"
-
-strip-ansi@^6.0.1:
+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==
@@ -5305,9 +5245,9 @@
   integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
 
 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==
+  version "6.14.2"
+  resolved "https://registry.yarnpkg.com/systemjs/-/systemjs-6.14.2.tgz#e289f959f8c8b407403bd39c6abaa16f2c13f316"
+  integrity sha512-1TlOwvKWdXxAY9vba+huLu99zrQURDWA8pUTYsRIYDZYQbGyK+pyEP4h4dlySsqo7ozyJBmYD20F+iUHhAltEg==
 
 table-layout@^1.0.2:
   version "1.0.2"
@@ -5319,6 +5259,19 @@
     typical "^5.2.0"
     wordwrapjs "^4.0.0"
 
+table-layout@^3.0.0:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-3.0.2.tgz#69c2be44388a5139b48c59cf21e73b488021769a"
+  integrity sha512-rpyNZYRw+/C+dYkcQ3Pr+rLxW4CfHpXjPDnG7lYhdRoUcZTUt+KEsX+94RGp/aVp/MQU35JCITv2T/beY4m+hw==
+  dependencies:
+    "@75lb/deep-merge" "^1.1.1"
+    array-back "^6.2.2"
+    command-line-args "^5.2.1"
+    command-line-usage "^7.0.0"
+    stream-read-all "^3.0.1"
+    typical "^7.1.1"
+    wordwrapjs "^5.1.0"
+
 tar-fs@2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784"
@@ -5340,7 +5293,7 @@
     inherits "^2.0.3"
     readable-stream "^3.1.1"
 
-terser@^4.6.3, terser@^4.6.7:
+terser@^4.6.3, terser@^4.6.7, terser@^4.8.1:
   version "4.8.1"
   resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.1.tgz#a00e5634562de2239fd404c649051bf6fc21144f"
   integrity sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==
@@ -5404,11 +5357,6 @@
   dependencies:
     is-number "^7.0.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==
-
 toidentifier@1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
@@ -5447,9 +5395,9 @@
   integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
 
 tslib@^2.0.3:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
-  integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==
+  version "2.6.2"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
+  integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
 
 tsscmp@1.0.6:
   version "1.0.6"
@@ -5496,15 +5444,20 @@
   resolved "https://registry.yarnpkg.com/typical/-/typical-5.2.0.tgz#4daaac4f2b5315460804f0acf6cb69c52bb93066"
   integrity sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==
 
-ua-parser-js@^0.7.30:
-  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==
+typical@^7.1.1:
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/typical/-/typical-7.1.1.tgz#ba177ab7ab103b78534463ffa4c0c9754523ac1f"
+  integrity sha512-T+tKVNs6Wu7IWiAce5BgMd7OZfNYUndHwc5MknN+UHOudi7sGZzuHdCadllRuqJ3fPtgFtIH9+lt9qRv6lmpfA==
 
-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==
+ua-parser-js@^0.7.30:
+  version "0.7.36"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.36.tgz#382c5d6fc09141b6541be2cae446ecfcec284db2"
+  integrity sha512-CPPLoCts2p7D8VbybttE3P2ylv0OBZEAy7a12DsulIEcAiMtWJy+PBgMXgWDI80D5UwqE8oQPHYnk13tm38M2Q==
+
+ua-parser-js@^1.0.33:
+  version "1.0.36"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.36.tgz#a9ab6b9bd3a8efb90bb0816674b412717b7c428c"
+  integrity sha512-znuyCIXzl8ciS3+y3fHJI/2OhQIXbXw9MWC/o3qwyR+RGppjZHrM27CGFSKCJXi2Kctiz537iOu2KnXs1lMQhw==
 
 unbzip2-stream@1.4.3:
   version "1.4.3"
@@ -5527,15 +5480,15 @@
     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-match-property-value-ecmascript@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz#cb5fffdcd16a05124f5a4b0bf7c3770208acbbe0"
+  integrity sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==
 
 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==
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz#43d41e3be698bd493ef911077c9b131f827e8ccd"
+  integrity sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==
 
 universalify@^0.1.0:
   version "0.1.2"
@@ -5545,12 +5498,12 @@
 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=
+  integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
 
-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==
+update-browserslist-db@^1.0.11:
+  version "1.0.12"
+  resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.12.tgz#868ce670ac09b4a4d4c86b608701c0dee2dc41cd"
+  integrity sha512-tE1smlR58jxbFMtrMpFNRmsrOXlpNXss965T1CrpwuZUzUAg/TBQc94SpyhDLSzrqrJS9xTRBthnZAGcE1oaxg==
   dependencies:
     escalade "^3.1.1"
     picocolors "^1.0.0"
@@ -5595,9 +5548,9 @@
     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==
+  version "9.1.0"
+  resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz#1b83ed4e397f58c85c266a570fc2558b5feb9265"
+  integrity sha512-6z3GW9x8G1gd+JIIgQQQxXuiJtCXeAjp6RaPEPLv62mH3iPHPxV6W3robxtCzNErRo6ZwTmzWhsbNvjyEBKzKA==
   dependencies:
     "@jridgewell/trace-mapping" "^0.3.12"
     "@types/istanbul-lib-coverage" "^2.0.1"
@@ -5619,7 +5572,7 @@
 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=
+  integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
 
 verror@1.10.0:
   version "1.10.0"
@@ -5651,9 +5604,9 @@
   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==
+  version "3.6.19"
+  resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.19.tgz#caefd92ae630b91c07345537e67f8354db470973"
+  integrity sha512-d67JP4dHSbm2TrpFj8AbO8DnL1JXL5J9u0Kq2xW6d0TFDbCA3Muhdt8orXC22utleTVj7Prqt82baN6RBvnEgw==
 
 whatwg-url@^11.0.0:
   version "11.0.0"
@@ -5702,6 +5655,11 @@
     reduce-flatten "^2.0.0"
     typical "^5.2.0"
 
+wordwrapjs@^5.1.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-5.1.0.tgz#4c4d20446dcc670b14fa115ef4f8fd9947af2b3a"
+  integrity sha512-JNjcULU2e4KJwUNv6CHgI46UvDGitb6dGryHajXTDiLgg1/RiGoPSDw4kZfYnwGtEXf2ZMeIewDQgFGzkCB2Sg==
+
 workerpool@6.2.0:
   version "6.2.0"
   resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.0.tgz#827d93c9ba23ee2019c3ffaff5c27fccea289e8b"
@@ -5728,7 +5686,7 @@
 wrappy@1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
-  integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
+  integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
 
 ws@8.5.0:
   version "8.5.0"
@@ -5736,14 +5694,14 @@
   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==
+  version "7.5.9"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591"
+  integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==
 
-ws@~8.2.3:
-  version "8.2.3"
-  resolved "https://registry.yarnpkg.com/ws/-/ws-8.2.3.tgz#63a56456db1b04367d0b721a0b80cae6d8becbba"
-  integrity sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==
+ws@~8.11.0:
+  version "8.11.0"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-8.11.0.tgz#6a0d36b8edfd9f96d8b25683db2f8d7de6e8e143"
+  integrity sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==
 
 y18n@^5.0.5:
   version "5.0.8"
@@ -5812,9 +5770,9 @@
     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"
-  integrity sha512-faQrqNMzcPCHGVC2aaOINk13K+aaBDUPjGWl0teOXywElLjyVAB6Oe2jj62jHYtwsU49jXhScYbvPENK+6zAvQ==
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.3.2.tgz#0de48017473275a4cbdfc83a1eaf67c01af8a785"
+  integrity sha512-RXRJzMiK6U2ye0BlGGZnmpwJDPgakn6aNQ0A7gHRbD4I0uvK4TW6UqkK1V0pp9jskjJBAXd3dRrbzWkqJ+6cxA==
 
 yocto-queue@^0.1.0:
   version "0.1.0"
diff --git a/prologtests/BUILD b/prologtests/BUILD
index 279dbb7..3a598be 100644
--- a/prologtests/BUILD
+++ b/prologtests/BUILD
@@ -1,5 +1,5 @@
 filegroup(
     name = "gerrit_common_test",
-    srcs = ["com/google/gerrit/server/rules/gerrit_common_test.pl"],
+    srcs = ["com/google/gerrit/server/rules/prolog/gerrit_common_test.pl"],
     visibility = ["//visibility:public"],
 )
diff --git a/prologtests/com/google/gerrit/server/rules/gerrit_common_test.pl b/prologtests/com/google/gerrit/server/rules/prolog/gerrit_common_test.pl
similarity index 100%
rename from prologtests/com/google/gerrit/server/rules/gerrit_common_test.pl
rename to prologtests/com/google/gerrit/server/rules/prolog/gerrit_common_test.pl
diff --git a/proto/cache.proto b/proto/cache.proto
index 7063ee5..87ae0e4 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -78,7 +78,7 @@
 // Instead, we just take the tedious yet simple approach of having a "has_foo"
 // field for each nullable field "foo", indicating whether or not foo is null.
 //
-// Next ID: 28
+// Next ID: 29
 message ChangeNotesStateProto {
   // Effectively required, even though the corresponding ChangeNotesState field
   // is optional, since the field is only absent when NoteDb is disabled, in
@@ -142,6 +142,8 @@
 
   repeated string hashtag = 5;
 
+  map<string, string> custom_keyed_values = 28;
+
   repeated devtools.gerritcodereview.PatchSet patch_set = 6;
 
   repeated devtools.gerritcodereview.PatchSetApproval approval = 7;
@@ -250,7 +252,7 @@
     // Next ID: 3
     message TagProto {
       bytes id = 1;
-      bytes flags = 2;
+      bytes flags = 2; // org.roaringbitmap.RoaringBitmap serialized as ByteString
     }
     repeated TagProto tag = 3;
   }
@@ -321,6 +323,15 @@
   repeated string notify_type = 3;
 }
 
+// Serialized user preferences.
+// Next ID: 3
+message CachedPreferencesProto {
+  oneof Preferences {
+    devtools.gerritcodereview.UserPreferences user_preferences = 1;
+    string legacy_git_config = 2;
+  }
+}
+
 // Serialized form of
 // com.google.gerrit.entities.Account.
 // Next ID: 9
@@ -343,11 +354,12 @@
 }
 
 // Serialized form of com.google.gerrit.server.account.CachedAccountDetails.
-// Next ID: 4
+// Next ID: 5
 message AccountDetailsProto {
   AccountProto account = 1;
   repeated ProjectWatchProto project_watch_proto = 2;
-  string user_preferences = 3;
+  CachedPreferencesProto user_preferences = 4;
+  reserved 3;
 }
 
 // Serialized form of com.google.gerrit.entities.Project.
diff --git a/proto/entities.proto b/proto/entities.proto
index f89e0f0..3412291 100644
--- a/proto/entities.proto
+++ b/proto/entities.proto
@@ -89,7 +89,7 @@
 }
 
 // Serialized form of com.google.gerrit.entities.PatchSet.
-// Next ID: 10
+// Next ID: 12
 message PatchSet {
   required PatchSet_Id id = 1;
   optional ObjectId commitId = 2;
@@ -99,6 +99,7 @@
   optional string push_certificate = 8;
   optional string description = 9;
   optional Account_Id real_uploader_account_id = 10;
+  optional string branch = 11;
 
   // Deleted fields, should not be reused:
   reserved 5;  // draft
@@ -161,3 +162,152 @@
   // Hex string representation of the ID.
   optional string name = 1;
 }
+
+// Serialized form of a continuation token used for pagination.
+// Next ID: 2
+message PaginationToken {
+  optional string next_page_token = 1;
+}
+
+// Proto representation of the User preferences classes
+// Next ID: 4
+message UserPreferences {
+  // Next ID: 24
+  message GeneralPreferencesInfo {
+    // Number of changes to show in a screen.
+    optional int32 changes_per_page = 1 [default = 25];
+
+    // Type of download URL the user prefers to use. */
+    optional string download_scheme = 2;
+
+    enum Theme {
+      AUTO = 0;
+      DARK = 1;
+      LIGHT = 2;
+    }
+    optional Theme theme = 3;
+
+    enum DateFormat {
+      STD = 0;
+      US = 1;
+      ISO = 2;
+      EURO = 3;
+      UK = 4;
+    }
+    optional DateFormat date_format = 4;
+
+    enum TimeFormat {
+      HHMM_12 = 0;
+      HHMM_24 = 1;
+    }
+    optional TimeFormat time_format = 5;
+
+    optional bool expand_inline_diffs = 6;
+    optional bool relative_date_in_change_table = 20;
+
+    enum DiffView {
+      SIDE_BY_SIDE = 0;
+      UNIFIED_DIFF = 1;
+    }
+    optional DiffView diff_view = 21;
+
+    optional bool size_bar_in_change_table = 22 [default = true];
+    optional bool legacycid_in_change_table = 7;
+    optional bool mute_common_path_prefixes = 8 [default = true];
+    optional bool signed_off_by = 9;
+
+    enum EmailStrategy {
+      ENABLED = 0;
+      CC_ON_OWN_COMMENTS = 1;
+      ATTENTION_SET_ONLY = 2;
+      DISABLED = 3;
+    }
+    optional EmailStrategy email_strategy = 10;
+
+    enum EmailFormat {
+      PLAINTEXT = 0;
+      HTML_PLAINTEXT = 1;
+    }
+    optional EmailFormat email_format = 11 [default = HTML_PLAINTEXT];
+
+    enum DefaultBase {
+      AUTO_MERGE = 0;
+      FIRST_PARENT = 1;
+    }
+    optional DefaultBase default_base_for_merges = 12 [default = FIRST_PARENT];
+
+    optional bool publish_comments_on_push = 13;
+    optional bool disable_keyboard_shortcuts = 14;
+    optional bool disable_token_highlighting = 15;
+    optional bool work_in_progress_by_default = 16;
+
+    message MenuItem {
+      optional string url = 1;
+      optional string name = 2;
+      optional string target = 3;
+      optional string id = 4;
+    }
+    repeated MenuItem my_menu_items = 17;
+
+    repeated string change_table = 18;
+    optional bool allow_browser_notifications = 19 [default = true];
+    optional string diff_page_sidebar = 23 [default = "NONE"];
+  }
+  optional GeneralPreferencesInfo general_preferences_info = 1;
+
+  // Next ID: 25
+  message DiffPreferencesInfo {
+    optional int32 context = 1 [default = 10];
+    optional int32 tab_size = 2 [default = 8];
+    optional int32 font_size = 3 [default = 12];
+    optional int32 line_length = 4 [default = 100];
+    optional int32 cursor_blink_rate = 5;
+    optional bool expand_all_comments = 6;
+    optional bool intraline_difference = 7 [default = true];
+    optional bool manual_review = 8;
+    optional bool show_line_endings = 9 [default = true];
+    optional bool show_tabs = 10 [default = true];
+    optional bool show_whitespace_errors = 11 [default = true];
+    optional bool syntax_highlighting = 12 [default = true];
+    optional bool hide_top_menu = 13;
+    optional bool auto_hide_diff_table_header = 14 [default = true];
+    optional bool hide_line_numbers = 15;
+    optional bool render_entire_file = 16;
+    optional bool hide_empty_pane = 17;
+    optional bool match_brackets = 18;
+    optional bool line_wrapping = 19;
+
+    enum Whitespace {
+      IGNORE_NONE = 0;
+      IGNORE_TRAILING = 1;
+      IGNORE_LEADING_AND_TRAILING = 2;
+      IGNORE_ALL = 3;
+    }
+    optional Whitespace ignore_whitespace = 20;
+
+    optional bool retain_header = 21;
+    optional bool skip_deleted = 22;
+    optional bool skip_unchanged = 23;
+    optional bool skip_uncommented = 24;
+  }
+  optional DiffPreferencesInfo diff_preferences_info = 2;
+
+  // Next ID: 15
+  message EditPreferencesInfo {
+    optional int32 tab_size = 1 [default = 8];
+    optional int32 line_length = 2 [default = 100];
+    optional int32 indent_unit = 3 [default = 2];
+    optional int32 cursor_blink_rate = 4;
+    optional bool hide_top_menu = 5;
+    optional bool show_tabs = 6 [default = true];
+    optional bool show_whitespace_errors = 7;
+    optional bool syntax_highlighting = 8 [default = true];
+    optional bool hide_line_numbers = 9;
+    optional bool match_brackets = 10 [default = true];
+    optional bool line_wrapping = 11;
+    optional bool indent_with_tabs = 12;
+    optional bool auto_close_brackets = 13;
+    optional bool show_base = 14;
+  }
+  optional EditPreferencesInfo edit_preferences_info = 3;
+}
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index dbfef44..b748ba5 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -104,7 +104,7 @@
     {/if}
   {/if}
   {if $userIsAuthenticated and $defaultDashboardHex and $dashboardQuery}
-    <link rel="preload" href="{$canonicalPath}/changes/?O={$defaultDashboardHex}&S=0{for $query in $dashboardQuery}&q={$query}{/for}" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
+    <link rel="preload" href="{$canonicalPath}/changes/?O={$defaultDashboardHex}&S=0{for $query in $dashboardQuery}&q={$query}{/for}&allow-incomplete-results=true" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
   {/if}
 
   {if $useGoogleFonts}
diff --git a/resources/com/google/gerrit/server/commit-msg_test.sh b/resources/com/google/gerrit/server/commit-msg_test.sh
index a14109d..c537a89 100755
--- a/resources/com/google/gerrit/server/commit-msg_test.sh
+++ b/resources/com/google/gerrit/server/commit-msg_test.sh
@@ -177,6 +177,30 @@
   fi
 }
 
+function test_preserve_link {
+  cat << EOF > input
+bla bla
+
+Link: https://myhost/id/I1234567890123456789012345678901234567890
+EOF
+
+  git config gerrit.reviewUrl https://myhost/
+  ${hook} input || fail "failed hook execution"
+  git config --unset gerrit.reviewUrl
+  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) || :
+  if [[ "${found}" != "1" ]]; then
+    fail "got ${found} Link footers, want 1"
+  fi
+  found=$(grep -c '^Link: https://myhost/id/I1234567890123456789012345678901234567890$' input) || :
+  if [[ "${found}" != "1" ]]; then
+    fail "got ${found} Link: https://myhost/id/I123..., want 1"
+  fi
+}
+
 # Change-Id goes after existing trailers.
 function test_at_end {
   cat << EOF > input
diff --git a/resources/com/google/gerrit/server/mail/AddKey.soy b/resources/com/google/gerrit/server/mail/AddKey.soy
index 319db05..8958ea3 100644
--- a/resources/com/google/gerrit/server/mail/AddKey.soy
+++ b/resources/com/google/gerrit/server/mail/AddKey.soy
@@ -48,9 +48,9 @@
   You can also manage your {$email.keyType} keys by visiting
   {\n}
   {if $email.sshKey}
-    {$email.gerritUrl}#/settings/ssh-keys
+    {$email.sshKeysSettingsUrl}
   {elseif $email.gpgKeys}
-    {$email.gerritUrl}#/settings/gpg-keys
+    {$email.gpgKeysSettingsUrl}
   {/if}
   {\n}
   {if $email.userNameEmail}
diff --git a/resources/com/google/gerrit/server/mail/AddKeyHtml.soy b/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
index c356a95..cb5b224 100644
--- a/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
+++ b/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
@@ -47,9 +47,9 @@
   <p>
     You can also manage your {$email.keyType} keys by following{sp}
     {if $email.sshKey}
-      <a href="{$email.gerritUrl}#/settings/ssh-keys">this link</a>
+      <a href="{$email.sshKeysSettingsUrl}">this link</a>
     {elseif $email.gpgKeys}
-      <a href="{$email.gerritUrl}#/settings/gpg-keys">this link</a>
+      <a href="{$email.gpgKeysSettingsUrl}">this link</a>
     {/if}
     {sp}
     {if $email.userNameEmail}
diff --git a/resources/com/google/gerrit/server/mail/ChangeHeader.soy b/resources/com/google/gerrit/server/mail/ChangeHeader.soy
index 027d78b..12b68b6 100644
--- a/resources/com/google/gerrit/server/mail/ChangeHeader.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeHeader.soy
@@ -17,8 +17,8 @@
 {namespace com.google.gerrit.server.mail.template.ChangeHeader}
 
 {template ChangeHeader kind="text"}
-  {@param attentionSet: ?}
-  {if $attentionSet}
+  {@param attentionSet: list<string>|null}
+  {if $attentionSet and length($attentionSet) > 0}
     Attention is currently required from:{sp}
     {for $attentionSetUser, $index in $attentionSet}
       {$attentionSetUser}
diff --git a/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy b/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
index 0d8da38..e17e021 100644
--- a/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
@@ -18,8 +18,8 @@
 {namespace com.google.gerrit.server.mail.template.ChangeHeaderHtml}
 
 {template ChangeHeaderHtml}
-  {@param attentionSet: ?}
-  {if $attentionSet}
+  {@param attentionSet: list<string>|null}
+  {if $attentionSet and length($attentionSet) > 0}
     <p> Attention is currently required from:{sp}
     {for $attentionSetUser, $index in $attentionSet}
       {$attentionSetUser}
diff --git a/resources/com/google/gerrit/server/mail/DeleteKey.soy b/resources/com/google/gerrit/server/mail/DeleteKey.soy
index 0957dc6..46bfc7e 100644
--- a/resources/com/google/gerrit/server/mail/DeleteKey.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteKey.soy
@@ -47,9 +47,9 @@
   You can also manage your {$email.keyType} keys by visiting
   {\n}
   {if $email.sshKey}
-    {$email.gerritUrl}#/settings/ssh-keys
+    {$email.sshKeysSettingsUrl}
   {elseif $email.gpgKey}
-    {$email.gerritUrl}#/settings/gpg-keys
+    {$email.gpgKeysSettingsUrl}
   {/if}
   {\n}
   {if $email.userNameEmail}
diff --git a/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy b/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy
index fea6785..539688e 100644
--- a/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy
@@ -45,9 +45,9 @@
   <p>
     You can also manage your {$email.keyType} keys by following{sp}
     {if $email.sshKey}
-      <a href="{$email.gerritUrl}#/settings/ssh-keys">this link</a>
+      <a href="{$email.sshKeysSettingsUrl}">this link</a>
     {elseif $email.gpgKeyFingerprints}
-      <a href="{$email.gerritUrl}#/settings/gpg-keys">this link</a>
+      <a href="{$email.gpgKeysSettingsUrl}">this link</a>
     {/if}
     {sp}
     {if $email.userNameEmail}
diff --git a/resources/com/google/gerrit/server/mail/Email.soy b/resources/com/google/gerrit/server/mail/Email.soy
new file mode 100644
index 0000000..9afea72
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/Email.soy
@@ -0,0 +1,27 @@
+/**
+ * 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.
+*/
+
+{namespace com.google.gerrit.server.mail.template.Email}
+
+/**
+ * The .Email template defines the structure of the content in the email.
+ */
+{template Email kind="text"}
+  {@param body: string}
+  {@param footer: string}
+  {$body}
+  {$footer}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/EmailHtml.soy b/resources/com/google/gerrit/server/mail/EmailHtml.soy
new file mode 100644
index 0000000..c2c69f8
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/EmailHtml.soy
@@ -0,0 +1,40 @@
+/**
+ * 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.
+*/
+
+{namespace com.google.gerrit.server.mail.template.EmailHtml}
+
+/**
+ * The .EmailHtml template defines the structure of the content in the email.
+ */
+{template EmailHtml}
+  {@param styles: css}
+  {@param body_sections_html: list<html>}
+  {@param footer_html: html}
+  <!DOCTYPE html>
+  <html>
+    <head>
+      <style>
+        {$styles}
+      </style>
+    </head>
+    <body>
+      {for $section in $body_sections_html}
+        {$section}
+      {/for}
+      {$footer_html}
+    </body>
+  </html>
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy b/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy
index 49fbccb..3efa8be 100644
--- a/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy
+++ b/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy
@@ -33,7 +33,7 @@
 
   You can also manage your HTTP password by visiting
   {\n}
-  {$email.gerritUrl}#/settings/http-password
+  {$email.httpPasswordSettingsUrl}
   {\n}
   {if $email.userNameEmail}
     (while signed in as {$email.userNameEmail})
diff --git a/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy b/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy
index 3f88a6f..ee033cd 100644
--- a/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy
+++ b/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy
@@ -30,7 +30,7 @@
 
   <p>
     You can also manage your HTTP password by following{sp}
-    <a href="{$email.gerritUrl}#/settings/http-password">this link</a>
+    <a href="{$email.httpPasswordSettingsUrl}">this link</a>
     {sp}
     {if $email.userNameEmail}
       (while signed in as {$email.userNameEmail})
diff --git a/resources/com/google/gerrit/server/mail/Private.soy b/resources/com/google/gerrit/server/mail/Private.soy
index 7920c21..be1f79b 100644
--- a/resources/com/google/gerrit/server/mail/Private.soy
+++ b/resources/com/google/gerrit/server/mail/Private.soy
@@ -38,7 +38,7 @@
                                       // monospace text.
     white-space: pre-wrap;
   {/let}
-  <pre style="{$preStyle}">{$content|changeNewlineToBr}</pre>
+  <pre class="blocks" style="{$preStyle}">{$content|changeNewlineToBr}</pre>
 {/template}
 
 /**
@@ -69,15 +69,15 @@
 
   {for $block in $content}
     {if $block.type == 'paragraph'}
-      <p style="{$pStyle}">{$block.text|changeNewlineToBr}</p>
+      <p class="blocks" style="{$pStyle}">{$block.text|changeNewlineToBr}</p>
     {elseif $block.type == 'quote'}
-      <blockquote style="{$blockquoteStyle}">
+      <blockquote class="blocks" style="{$blockquoteStyle}">
         {call WikiFormat}{param content: $block.quotedBlocks /}{/call}
       </blockquote>
     {elseif $block.type == 'pre'}
       {call Pre}{param content: $block.text /}{/call}
     {elseif $block.type == 'list'}
-      <ul>
+      <ul class="blocks">
         {for $item in $block.items}
           <li>{$item}</li>
         {/for}
diff --git a/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy b/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
index 273f52f..cd38742 100644
--- a/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
+++ b/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
@@ -34,7 +34,7 @@
 
   {\n}
 
-  {$email.gerritUrl}#/VE/{$email.emailRegistrationToken}{\n}
+  {$email.emailRegistrationLink}{\n}
 
   {\n}
 
diff --git a/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy b/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
index 7d6cd23..20f9999 100644
--- a/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
+++ b/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
@@ -31,7 +31,7 @@
 
   <p>
 
-    {$email.gerritUrl}#/VE/{$email.emailRegistrationToken}
+    {$email.emailRegistrationLink}
   </p>
   <p>
     If you have received this mail in error, you do not need to take any
diff --git a/resources/com/google/gerrit/server/mime/mime-types.properties b/resources/com/google/gerrit/server/mime/mime-types.properties
index 8e97ba7..2626059 100644
--- a/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -14,7 +14,7 @@
 bzl = text/x-python
 BUCK = text/x-python
 BUILD = text/x-python
-BUILD.bazel = text/x-python
+bazel = text/x-python
 c = text/x-csrc
 cfg = text/x-ttcn-cfg
 cl = text/x-common-lisp
@@ -205,7 +205,7 @@
 sas = text/x-sas
 sass = text/x-sass
 scala = text/x-scala
-scl = text/x-iecst
+scl = text/x-python
 scm = text/x-scheme
 scss = text/x-scss
 sh = text/x-sh
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 0154d43..5c7dffa 100755
--- a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+++ b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
@@ -64,7 +64,7 @@
 if test -n "${reviewurl}" ; then
   token="Link"
   value="${reviewurl%/}/id/I$random"
-  pattern=".*/id/I[0-9a-f]\{40\}$"
+  pattern=".*/id/I[0-9a-f]\{40\}"
 else
   token="Change-Id"
   value="I$random"
diff --git a/tools/BUILD b/tools/BUILD
index b6a3572..cb25c47 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -189,7 +189,7 @@
         "-Xep:ImmutableAnnotationChecker:ERROR",
         "-Xep:ImmutableEnumChecker:ERROR",
         "-Xep:ImmutableModification:ERROR",
-        "-Xep:ImpossibleNullComparison:OFF",
+        "-Xep:ImpossibleNullComparison:ERROR",
         "-Xep:Incomparable:ERROR",
         "-Xep:IncompatibleArgumentType:ERROR",
         "-Xep:IncompatibleModifiers:ERROR",
@@ -202,7 +202,7 @@
         "-Xep:InjectOnConstructorOfAbstractClass:ERROR",
         "-Xep:InheritDoc:ERROR",
         "-Xep:InlineFormatString:ERROR",
-        "-Xep:InlineMeInliner:ERROR",
+        "-Xep:InlineMeInliner:OFF",
         "-Xep:InlineMeSuggester:ERROR",
         "-Xep:InlineMeValidator:ERROR",
         "-Xep:InputStreamSlowMultibyteRead:ERROR",
@@ -258,7 +258,7 @@
         "-Xep:JodaWithDurationAddedLong:ERROR",
         "-Xep:LiteByteStringUtf8:ERROR",
         "-Xep:LiteEnumValueOf:ERROR",
-        "-Xep:LiteProtoToString:OFF",
+        "-Xep:LiteProtoToString:ERROR",
         "-Xep:LocalDateTemporalAmount:ERROR",
         "-Xep:LockNotBeforeTry:ERROR",
         "-Xep:LockOnBoxedPrimitive:ERROR",
diff --git a/tools/bzl/classpath.bzl b/tools/bzl/classpath.bzl
index 3be7a12..3c80fc3 100644
--- a/tools/bzl/classpath.bzl
+++ b/tools/bzl/classpath.bzl
@@ -2,7 +2,7 @@
     all = []
     for d in ctx.attr.deps:
         if JavaInfo in d:
-            all.append(d[JavaInfo].transitive_runtime_deps)
+            all.append(d[JavaInfo].transitive_runtime_jars)
             if hasattr(d[JavaInfo].compilation_info, "runtime_classpath"):
                 all.append(d[JavaInfo].compilation_info.runtime_classpath)
         elif hasattr(d, "files"):
diff --git a/tools/bzl/javadoc.bzl b/tools/bzl/javadoc.bzl
index abf3b7a..5aba90e 100644
--- a/tools/bzl/javadoc.bzl
+++ b/tools/bzl/javadoc.bzl
@@ -17,7 +17,7 @@
 def _impl(ctx):
     zip_output = ctx.outputs.zip
 
-    transitive_jars = depset(transitive = [j[JavaInfo].transitive_deps for j in ctx.attr.libs])
+    transitive_jars = depset(transitive = [j[JavaInfo].transitive_compile_time_jars for j in ctx.attr.libs])
 
     # TODO(davido): Remove list to depset conversion on source_jars, when this issue is fixed:
     # https://github.com/bazelbuild/bazel/issues/4221
diff --git a/tools/bzl/junit.bzl b/tools/bzl/junit.bzl
index 4659c48..d10e113 100644
--- a/tools/bzl/junit.bzl
+++ b/tools/bzl/junit.bzl
@@ -70,12 +70,7 @@
 POST_JDK8_OPTS = [
     # Enforce JDK 8 compatibility on Java 9, see
     # https://docs.oracle.com/javase/9/intl/internationalization-enhancements-jdk-9.htm#JSINT-GUID-AF5AECA7-07C1-4E7D-BC10-BC7E73DC6C7F
-    "-Djava.locale.providers=COMPAT,CLDR,SPI",
-]
-
-POST_JDK17_OPTS = [
-    # https://github.com/bazelbuild/bazel/issues/14502
-    "-Djava.security.manager=allow",
+    "-Djava.locale.providers=COMPAT",
 ]
 
 def junit_tests(name, srcs, **kwargs):
@@ -86,10 +81,7 @@
         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,
-    })
+    jvm_flags = jvm_flags + POST_JDK8_OPTS
     java_test(
         name = name,
         test_class = s_name,
diff --git a/tools/bzl/pkg_war.bzl b/tools/bzl/pkg_war.bzl
index e2be145..4792de2 100644
--- a/tools/bzl/pkg_war.bzl
+++ b/tools/bzl/pkg_war.bzl
@@ -88,7 +88,7 @@
     transitive_libs = []
     for j in ctx.attr.libs:
         if JavaInfo in j:
-            transitive_libs.append(j[JavaInfo].transitive_runtime_deps)
+            transitive_libs.append(j[JavaInfo].transitive_runtime_jars)
         elif hasattr(j, "files"):
             transitive_libs.append(j.files)
 
@@ -102,7 +102,7 @@
     # Add pgm lib
     transitive_pgmlibs = []
     for j in ctx.attr.pgmlibs:
-        transitive_pgmlibs.append(j[JavaInfo].transitive_runtime_deps)
+        transitive_pgmlibs.append(j[JavaInfo].transitive_runtime_jars)
 
     transitive_pgmlib_deps = depset(transitive = transitive_pgmlibs)
     for dep in transitive_pgmlib_deps.to_list():
@@ -117,7 +117,7 @@
     if ctx.attr.context:
         for jar in ctx.attr.context:
             if JavaInfo in jar:
-                transitive_context_libs.append(jar[JavaInfo].transitive_runtime_deps)
+                transitive_context_libs.append(jar[JavaInfo].transitive_runtime_jars)
             elif hasattr(jar, "files"):
                 transitive_context_libs.append(jar.files)
 
diff --git a/tools/deps.bzl b/tools/deps.bzl
index 52e848f..100670c 100644
--- a/tools/deps.bzl
+++ b/tools/deps.bzl
@@ -3,7 +3,6 @@
 CAFFEINE_VERS = "2.9.2"
 ANTLR_VERS = "3.5.2"
 COMMONMARK_VERSION = "0.21.0"
-FLEXMARK_VERS = "0.50.50"
 GREENMAIL_VERS = "1.5.5"
 MAIL_VERS = "1.6.0"
 MIME4J_VERS = "0.8.1"
@@ -14,14 +13,15 @@
 AUTO_VALUE_GSON_VERSION = "1.3.1"
 PROLOG_VERS = "1.4.4"
 PROLOG_REPO = GERRIT
-GITILES_VERS = "1.1.0"
+GITILES_VERS = "1.5.0"
 GITILES_REPO = GERRIT
 
 # When updating Bouncy Castle, also update it in bazlets.
 BC_VERS = "1.72"
 HTTPCOMP_VERS = "4.5.2"
 JETTY_VERS = "9.4.53.v20231009"
-BYTE_BUDDY_VERSION = "1.10.7"
+BYTE_BUDDY_VERSION = "1.14.9"
+ROARING_BITMAP_VERSION = "0.9.44"
 
 def java_dependencies():
     maven_jar(
@@ -67,6 +67,12 @@
         sha1 = "3cd63d075497751784b2fa84be59432f4905bf7c",
     )
 
+    maven_jar(
+        name = "jakarta-inject-api",
+        artifact = "jakarta.inject:jakarta.inject-api:2.0.1",
+        sha1 = "4c28afe1991a941d7702fe1362c365f0a8641d1e",
+    )
+
     # JGit's transitive dependencies
 
     maven_jar(
@@ -191,157 +197,15 @@
     maven_jar(
         name = "gfm-tables",
         artifact = "org.commonmark:commonmark-ext-gfm-tables:" + COMMONMARK_VERSION,
+        attach_source = False,
         sha1 = "fb7d65fa89a4cfcd2f51535d2549b570cf1dbd1a",
     )
 
     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",
+        name = "flexmark-all-lib",
+        artifact = "com.vladsch.flexmark:flexmark-all:0.64.0:lib",
+        attach_source = False,
+        sha1 = "de92cef20c1f61681a3c8f64dd5975fbd3125049",
     )
 
     # Transitive dependency of flexmark and gitiles
@@ -527,21 +391,14 @@
         artifact = "com.google.gitiles:blame-cache:" + GITILES_VERS,
         attach_source = False,
         repository = GITILES_REPO,
-        sha1 = "31c1a6e5d92b57bb2f9db24e1032145961c09a8d",
+        sha1 = "b398a6afa71a722bac29bc2fa69c27a582cc0e2b",
     )
 
     maven_jar(
         name = "gitiles-servlet",
         artifact = "com.google.gitiles:gitiles-servlet:" + GITILES_VERS,
         repository = GITILES_REPO,
-        sha1 = "c6550362c5c22d8e07edd4e2151ee12594082e76",
-    )
-
-    # prettify must match the version used in Gitiles
-    maven_jar(
-        name = "prettify",
-        artifact = "com.github.twalcari:java-prettify:1.2.2",
-        sha1 = "b8ba1c1eb8b2e45cfd465d01218c6060e887572e",
+        sha1 = "a32de9f1065001b5a8dd3ef9396833da643dcdb3",
     )
 
     maven_jar(
@@ -686,20 +543,20 @@
 
     maven_jar(
         name = "mockito",
-        artifact = "org.mockito:mockito-core:3.3.3",
-        sha1 = "4878395d4e63173f3825e17e5e0690e8054445f1",
+        artifact = "org.mockito:mockito-core:5.6.0",
+        sha1 = "550b7a0eb22e1d72d33dcc2e5ef6954f73100d76",
     )
 
     maven_jar(
         name = "bytebuddy",
         artifact = "net.bytebuddy:byte-buddy:" + BYTE_BUDDY_VERSION,
-        sha1 = "1eefb7dd1b032b33c773ca0a17d5cc9e6b56ea1a",
+        sha1 = "b69e7fff6c473d3ed2b489cdfd673a091fd94226",
     )
 
     maven_jar(
         name = "bytebuddy-agent",
         artifact = "net.bytebuddy:byte-buddy-agent:" + BYTE_BUDDY_VERSION,
-        sha1 = "c472fad33f617228601172682aa64f8b78508045",
+        sha1 = "dfb8707031008535048bad2b69735f46d0b6c5e5",
     )
 
     maven_jar(
@@ -707,3 +564,15 @@
         artifact = "org.objenesis:objenesis:3.0.1",
         sha1 = "11cfac598df9dc48bb9ed9357ed04212694b7808",
     )
+
+    maven_jar(
+        name = "roaringbitmap",
+        artifact = "org.roaringbitmap:RoaringBitmap:" + ROARING_BITMAP_VERSION,
+        sha1 = "d25b4bcb67193d587f6e0617da2c6f84e2d02a9c",
+    )
+
+    maven_jar(
+        name = "roaringbitmap-shims",
+        artifact = "org.roaringbitmap:shims:" + ROARING_BITMAP_VERSION,
+        sha1 = "e22be0d690a99c046bf9f57106065a77edad1eda",
+    )
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index dc136ed..9e54e7f 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -24,7 +24,7 @@
 MAIN = '//tools/eclipse:classpath'
 AUTO = '//lib/auto:auto-value'
 
-def JRE(java_vers = '11'):
+def JRE(java_vers = '17'):
     return '/'.join([
         'org.eclipse.jdt.launching.JRE_CONTAINER',
         'org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType',
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index f870526..b8dd27c 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.8.10-SNAPSHOT</version>
+  <version>3.9.8-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index ebfb0fd..06cbab6 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.8.10-SNAPSHOT</version>
+  <version>3.9.8-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index c8a0236..18abd65 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.8.10-SNAPSHOT</version>
+  <version>3.9.8-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index bde0b04..0e86c88 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.8.10-SNAPSHOT</version>
+  <version>3.9.8-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/tools/node_tools/package.json b/tools/node_tools/package.json
index 3765575..bd4eb5c 100644
--- a/tools/node_tools/package.json
+++ b/tools/node_tools/package.json
@@ -9,16 +9,15 @@
     "@types/node": "^10.17.12",
     "@types/parse5": "^4.0.0",
     "@types/parse5-html-rewriting-stream": "^5.1.2",
-    "crisper": "^2.1.1",
     "dom5": "^3.0.1",
     "parse5-html-rewriting-stream": "^5.1.1",
-    "polymer-bundler": "^4.0.10",
-    "rollup": "^2.3.4",
+    "rollup": "^2.79.1",
     "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.7.2"
+    "rollup-plugin-terser": "^7.0.2",
+    "terser": "~5.8.0",
+    "typescript": "^4.9.5"
   },
   "devDependencies": {},
   "license": "Apache-2.0",
@@ -28,4 +27,4 @@
     "wct-local": "2.1.6",
     "launchpad": "git+https://github.com/418sec/launchpad.git#de5aca11dc16a8e530195281c77614bdbb08e7be"
   }
-}
+}
\ No newline at end of file
diff --git a/tools/node_tools/yarn.lock b/tools/node_tools/yarn.lock
index c6b3eab..9f3cada 100644
--- a/tools/node_tools/yarn.lock
+++ b/tools/node_tools/yarn.lock
@@ -2,101 +2,28 @@
 # yarn lockfile v1
 
 
-"@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==
+"@babel/code-frame@^7.10.4":
+  version "7.22.13"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.13.tgz#e3c1c099402598483b7a8c46a721d1038803755e"
+  integrity sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==
   dependencies:
-    "@babel/highlight" "^7.16.7"
+    "@babel/highlight" "^7.22.13"
+    chalk "^2.4.2"
 
-"@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==
+"@babel/helper-validator-identifier@^7.22.20":
+  version "7.22.20"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0"
+  integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==
+
+"@babel/highlight@^7.22.13":
+  version "7.22.20"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.20.tgz#4ca92b71d80554b01427815e06f2df965b9c1f54"
+  integrity sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==
   dependencies:
-    "@babel/types" "^7.18.2"
-    "@jridgewell/gen-mapping" "^0.3.0"
-    jsesc "^2.5.1"
-
-"@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/template" "^7.16.7"
-    "@babel/types" "^7.17.0"
-
-"@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/types" "^7.16.7"
-
-"@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/types" "^7.16.7"
-
-"@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-validator-identifier" "^7.16.7"
-    chalk "^2.0.0"
+    "@babel/helper-validator-identifier" "^7.22.20"
+    chalk "^2.4.2"
     js-tokens "^4.0.0"
 
-"@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/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/code-frame" "^7.16.7"
-    "@babel/parser" "^7.16.7"
-    "@babel/types" "^7.16.7"
-
-"@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/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.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.16.7"
-    to-fast-properties "^2.0.0"
-
 "@bazel/concatjs@^5.5.0":
   version "5.5.0"
   resolved "https://registry.yarnpkg.com/@bazel/concatjs/-/concatjs-5.5.0.tgz#e6104ed70595cae59463ae6b0b5389252566221e"
@@ -132,36 +59,44 @@
     google-protobuf "^3.6.1"
 
 "@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==
+  version "0.3.3"
+  resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz#7e02e6eb5df901aaedb08514203b096614024098"
+  integrity sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==
   dependencies:
-    "@jridgewell/set-array" "^1.0.0"
+    "@jridgewell/set-array" "^1.0.1"
     "@jridgewell/sourcemap-codec" "^1.4.10"
     "@jridgewell/trace-mapping" "^0.3.9"
 
-"@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==
+"@jridgewell/resolve-uri@^3.1.0":
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz#c08679063f279615a3326583ba3a90d1d82cc721"
+  integrity sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==
 
-"@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/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.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/source-map@^0.3.3":
+  version "0.3.5"
+  resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.5.tgz#a3bb4d5c6825aab0d281268f47f6ad5853431e91"
+  integrity sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==
+  dependencies:
+    "@jridgewell/gen-mapping" "^0.3.0"
+    "@jridgewell/trace-mapping" "^0.3.9"
+
+"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14":
+  version "1.4.15"
+  resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32"
+  integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==
 
 "@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==
+  version "0.3.20"
+  resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz#72e45707cf240fa6b081d0366f8265b0cd10197f"
+  integrity sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==
   dependencies:
-    "@jridgewell/resolve-uri" "^3.0.3"
-    "@jridgewell/sourcemap-codec" "^1.4.10"
+    "@jridgewell/resolve-uri" "^3.1.0"
+    "@jridgewell/sourcemap-codec" "^1.4.14"
 
 "@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2":
   version "1.1.2"
@@ -236,37 +171,6 @@
   dependencies:
     defer-to-connect "^2.0.0"
 
-"@types/babel-generator@^6.25.1":
-  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" "*"
-
-"@types/babel-traverse@^6.25.2", "@types/babel-traverse@^6.25.3":
-  version "6.25.7"
-  resolved "https://registry.yarnpkg.com/@types/babel-traverse/-/babel-traverse-6.25.7.tgz#bc75fce23d8394534562a36a32dec94a54d11835"
-  integrity sha512-BeQiEGLnVzypzBdsexEpZAHUx+WucOMXW6srEWDkl4SegBlaCy+iBvRO+4vz6EZ+BNQg22G4MCdDdvZxf+jW5A==
-  dependencies:
-    "@types/babel-types" "*"
-
-"@types/babel-types@*":
-  version "7.0.11"
-  resolved "https://registry.yarnpkg.com/@types/babel-types/-/babel-types-7.0.11.tgz#263b113fa396fac4373188d73225297fb86f19a9"
-  integrity sha512-pkPtJUUY+Vwv6B1inAz55rQvivClHJxc9aVEPPmaq2cbyeMLCiDpbKpcKyX4LAwpNGi+SHBv0tHv6+0gXv0P2A==
-
-"@types/babel-types@^6.25.1":
-  version "6.25.2"
-  resolved "https://registry.yarnpkg.com/@types/babel-types/-/babel-types-6.25.2.tgz#5c57f45973e4f13742dbc5273dd84cffe7373a9e"
-  integrity sha512-+3bMuktcY4a70a0KZc8aPJlEOArPuAKQYHU5ErjkOqGJdx8xuEEVK6nWogqigBOJ8nKPxRpyCUDTCPmZ3bUxGA==
-
-"@types/babylon@^6.16.2":
-  version "6.16.6"
-  resolved "https://registry.yarnpkg.com/@types/babylon/-/babylon-6.16.6.tgz#a1e7e01567b26a5ebad321a74d10299189d8d932"
-  integrity sha512-G4yqdVlhr6YhzLXFKy5F7HtRBU8Y23+iWy7UKthMq/OSQnL1hbsoeXESQ2LY8zEDlknipDG3nRGhUC9tkwvy/w==
-  dependencies:
-    "@types/babel-types" "*"
-
 "@types/body-parser@*":
   version "1.19.2"
   resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0"
@@ -285,28 +189,6 @@
     "@types/node" "*"
     "@types/responselike" "*"
 
-"@types/chai-subset@^1.3.0":
-  version "1.3.3"
-  resolved "https://registry.yarnpkg.com/@types/chai-subset/-/chai-subset-1.3.3.tgz#97893814e92abd2c534de422cb377e0e0bdaac94"
-  integrity sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==
-  dependencies:
-    "@types/chai" "*"
-
-"@types/chai@*":
-  version "4.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 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 sha512-vcxBr+ybljeSiasmdke1cQ9ICxoEwaBgM1OQ/P5h4MPj/kRyLcDl5L8PrftlbyV1kBbJIs3M3x1A1+rcWd4mEA==
-
 "@types/connect@*":
   version "3.4.35"
   resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1"
@@ -314,16 +196,6 @@
   dependencies:
     "@types/node" "*"
 
-"@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/doctrine@^0.0.1":
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/@types/doctrine/-/doctrine-0.0.1.tgz#b999f2d9f7b43cabe0a1a2f39bc203bc7dcada9d"
-  integrity sha512-iN9ewNbXmuWLOAB3wk/YpCqIBWK3wBNE1D/4u+jA/GyrqsE4r3ozbpS5F0fr0tIYmmnqhbVvT9OOXzt+vw+LDg==
-
 "@types/estree@*":
   version "0.0.51"
   resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40"
@@ -358,11 +230,6 @@
   resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz#0ea7b61496902b95890dc4c3a116b60cb8dae812"
   integrity sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==
 
-"@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 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"
@@ -390,31 +257,16 @@
   resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
   integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==
 
-"@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/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"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-6.0.118.tgz#8014a9b1dee0b72b4d7cd142563f1af21241c3a2"
-  integrity sha512-N33cKXGSqhOYaPiT4xUGsYlPPDwFtQM/6QxJxuMXA/7BcySW+lkn2yigWP7vfs4daiL/7NJNU6DMCqg5N4B+xQ==
-
 "@types/node@^10.1.0", "@types/node@^10.17.12":
   version "10.17.60"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.60.tgz#35f3d6213daed95da7f0f73e75bcc6980e90597b"
   integrity sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==
 
-"@types/node@^4.0.30":
-  version "4.9.5"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-4.9.5.tgz#a3785db96b07a4b56466cc99fd624838746f2e25"
-  integrity sha512-+8fpgbXsbATKRF2ayAlYhPl2E9MPdLjrnK/79ZEpyPJ+k7dZwJm9YM8FK+l4rqL//xHk7PgQhGwz6aA2ckxbCQ==
-
 "@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"
@@ -435,13 +287,6 @@
   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 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"
@@ -456,11 +301,6 @@
   dependencies:
     "@types/node" "*"
 
-"@types/path-is-inside@^1.0.0":
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/@types/path-is-inside/-/path-is-inside-1.0.0.tgz#02d6ff38975d684bdec96204494baf9f29f0e17f"
-  integrity sha512-hfnXRGugz+McgX2jxyy5qz9sB21LRzlGn24zlwN2KEgoPtEvjzNRrLtUkOOebPDPZl3Rq7ywKxYvylVcEZDnEw==
-
 "@types/qs@*":
   version "6.9.7"
   resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
@@ -471,13 +311,6 @@
   resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
   integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
 
-"@types/resolve@0.0.6":
-  version "0.0.6"
-  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.6.tgz#0bd2f236c2e1cebb98b79885df57edd71a8d770e"
-  integrity sha512-g+Rg8uMWY76oYTyaL+m7ZcblqF/oj7pE6uEUyACluJx4zcop1Lk14qQiocdEkEVMDFm6DmKpxJhsER+ZuTwG3g==
-  dependencies:
-    "@types/node" "*"
-
 "@types/resolve@0.0.8":
   version "0.0.8"
   resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.8.tgz#f26074d238e02659e323ce1a13d041eee280e194"
@@ -500,39 +333,15 @@
     "@types/mime" "^1"
     "@types/node" "*"
 
-"@types/whatwg-url@^6.4.0":
-  version "6.4.0"
-  resolved "https://registry.yarnpkg.com/@types/whatwg-url/-/whatwg-url-6.4.0.tgz#1e59b8c64bc0dbdf66d037cf8449d1c3d5270237"
-  integrity sha512-tonhlcbQ2eho09am6RHnHOgvtDfDYINd5rgxD+2YSkKENooVCFsWizJz139MQW/PV8FfClyKrNe9ZbdHrSCxGg==
-  dependencies:
-    "@types/node" "*"
-
 "@types/which@^1.3.1":
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/@types/which/-/which-1.3.2.tgz#9c246fc0c93ded311c8512df2891fb41f6227fdf"
   integrity sha512-8oDqyLC7eD4HM307boe2QWKyuzdzWBj56xI/imSl2cpL+U3tCMaTAkMJ4ee5JBZ/FsOJlvRGeIShiZDAl1qERA==
 
-acorn-jsx@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-3.0.1.tgz#afdf9488fb1ecefc8348f6fb22f464e32a58b36b"
-  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 sha512-OLUyIIZ7mF5oaAUT1w0TFqQS81q3saT46x8t7ukpPjMNk+nbs4ZHhs7ToV8EWnLYLepjETXd4XaCE4uxkMeqUw==
-
-acorn@^5.5.0:
-  version "5.7.4"
-  resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.4.tgz#3e8d8a9947d0599a1796d10225d7432f4a4acf5e"
-  integrity sha512-1D++VG7BhrtvQpNbBzovKNc1FLGGEE/oGe7b9xJm/RFHMBeUaUGpluV9RLjZa47YFdPcDAenEYuq9pQPcMdLJg==
-
-acorn@^7.1.0:
-  version "7.4.1"
-  resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
-  integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
+acorn@^8.8.2:
+  version "8.10.0"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.10.0.tgz#8be5b3907a67221a81ab23c7889c4c5526b62ec5"
+  integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==
 
 agent-base@^4.3.0:
   version "4.3.0"
@@ -541,23 +350,6 @@
   dependencies:
     es6-promisify "^5.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 sha512-nOj2mwGB2lJzx9YDqaiI77vYh4SWcOCTday6kdtx6ojUk1s1HqSiK604UIq8jlBVC0UBsX7Bph3SfOf9QsJerA==
-  dependencies:
-    array-back "^1.0.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 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 sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==
-
 ansi-styles@^3.2.1:
   version "3.2.1"
   resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
@@ -565,25 +357,6 @@
   dependencies:
     color-convert "^1.9.0"
 
-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 sha512-1WxbZvrmyhkNoeYcizokbmh5oiOCIfyvGtcqbK3Ls1v1fKcquzxnQSceOx6tzq7jmai2kFLWIpGND2cLhH6TPw==
-  dependencies:
-    typical "^2.6.0"
-
-array-back@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/array-back/-/array-back-2.0.0.tgz#6877471d51ecc9c9bfa6136fb6c7d5fe69748022"
-  integrity sha512-eJv4pLLufP3g5kcZry0j6WXpIbzYw9GUB4mVJZno9wfwiBxbizTnHCw3VJb07cBihbFX48Y7oSrW9y+gt4glyw==
-  dependencies:
-    typical "^2.6.1"
-
-array-back@^3.0.1, 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==
-
 ast-matcher@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/ast-matcher/-/ast-matcher-1.1.1.tgz#95a6dc72318319507024fff438b7839e4e280813"
@@ -596,79 +369,6 @@
   dependencies:
     lodash "^4.17.14"
 
-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 sha512-XqYMR2dfdGMW+hd0IUZ2PwK+fGeFkOxZJ0wY+JaQAHzt1Zx8LcvpiZD2NiGkEG8qx0CfkAOr5xt76d1e8vG90g==
-  dependencies:
-    chalk "^1.1.3"
-    esutils "^2.0.2"
-    js-tokens "^3.0.2"
-
-babel-generator@^6.26.1:
-  version "6.26.1"
-  resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.26.1.tgz#1844408d3b8f0d35a404ea7ac180f087a601bd90"
-  integrity sha512-HyfwY6ApZj7BYTcJURpM5tznulaBvyio7/0d4zFOeMPUmfxkCjHocCuoLa2SAGzBI8AREcH3eP3758F672DppA==
-  dependencies:
-    babel-messages "^6.23.0"
-    babel-runtime "^6.26.0"
-    babel-types "^6.26.0"
-    detect-indent "^4.0.0"
-    jsesc "^1.3.0"
-    lodash "^4.17.4"
-    source-map "^0.5.7"
-    trim-right "^1.0.1"
-
-babel-messages@^6.23.0:
-  version "6.23.0"
-  resolved "https://registry.yarnpkg.com/babel-messages/-/babel-messages-6.23.0.tgz#f3cdf4703858035b2a2951c6ec5edf6c62f2630e"
-  integrity sha512-Bl3ZiA+LjqaMtNYopA9TYE9HP1tQ+E5dLxE0XrAzcIJeK2UqF0/EaqXwBn9esd4UmTfEab+P+UYQ1GnioFIb/w==
-  dependencies:
-    babel-runtime "^6.22.0"
-
-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 sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==
-  dependencies:
-    core-js "^2.4.0"
-    regenerator-runtime "^0.11.0"
-
-babel-traverse@^6.26.0:
-  version "6.26.0"
-  resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.26.0.tgz#46a9cbd7edcc62c8e5c064e2d2d8d0f4035766ee"
-  integrity sha512-iSxeXx7apsjCHe9c7n8VtRXGzI2Bk1rBSOJgCCjfyXb6v1aCqE1KSEpq/8SXuVN8Ka/Rh1WDTF0MDzkvTA4MIA==
-  dependencies:
-    babel-code-frame "^6.26.0"
-    babel-messages "^6.23.0"
-    babel-runtime "^6.26.0"
-    babel-types "^6.26.0"
-    babylon "^6.18.0"
-    debug "^2.6.8"
-    globals "^9.18.0"
-    invariant "^2.2.2"
-    lodash "^4.17.4"
-
-babel-types@^6.26.0:
-  version "6.26.0"
-  resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.26.0.tgz#a3b073f94ab49eb6fa55cd65227a334380632497"
-  integrity sha512-zhe3V/26rCWsEZK8kZN+HaQj5yQ1CilTObixFzKW1UWjqG7618Twz6YEsCnjfg5gBcJh02DrpCkS9h98ZqDY+g==
-  dependencies:
-    babel-runtime "^6.26.0"
-    esutils "^2.0.2"
-    lodash "^4.17.4"
-    to-fast-properties "^1.0.3"
-
-babylon@^6.18.0:
-  version "6.18.0"
-  resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3"
-  integrity sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==
-
-babylon@^7.0.0-beta.42:
-  version "7.0.0-beta.47"
-  resolved "https://registry.yarnpkg.com/babylon/-/babylon-7.0.0-beta.47.tgz#6d1fa44f0abec41ab7c780481e62fd9aafbdea80"
-  integrity sha512-+rq2cr4GDhtToEzKFD6KZZMDBXhjFAr9JjPw9pAppZACeEWqNM294j+NdBzkSHYXwzzBmVjZ3nEVJlOhbR2gOQ==
-
 balanced-match@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
@@ -749,25 +449,7 @@
     normalize-url "^6.0.1"
     responselike "^2.0.0"
 
-cancel-token@^0.1.1:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/cancel-token/-/cancel-token-0.1.1.tgz#c18197674bb1c84c1d6933ebf15d8d5a5ce79b4f"
-  integrity sha512-22DV8aB/ovjL6l9S+QLwFzyP5+azENgfNywoJffIE8ZNx2Nnz7UlJ0mEULTtaeuf+tmnvaUdN6WKtV1LTBlbuA==
-  dependencies:
-    "@types/node" "^4.0.30"
-
-chalk@^1.1.3:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
-  integrity sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==
-  dependencies:
-    ansi-styles "^2.2.1"
-    escape-string-regexp "^1.0.2"
-    has-ansi "^2.0.0"
-    strip-ansi "^3.0.0"
-    supports-color "^2.0.0"
-
-chalk@^2.0.0, chalk@^2.3.0, chalk@^2.4.1:
+chalk@^2.3.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==
@@ -788,12 +470,7 @@
   dependencies:
     mimic-response "^1.0.0"
 
-clone@^1.0.2:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
-  integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==
-
-clone@^2.0.0, clone@^2.1.0:
+clone@^2.1.0:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
   integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==
@@ -810,47 +487,6 @@
   resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
   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 sha512-M29kjOI24VF4HqatnqVyDqyeq3SYYZbq6LWv/AdVZ5LvrcqVNSN2XeYPrBxcO19T8YkGmyCqTUqYR07DFjVhyg==
-  dependencies:
-    array-back "^1.0.4"
-    feature-detect-es6 "^1.3.1"
-    find-replace "^1.0.2"
-    typical "^2.6.0"
-
-command-line-args@^5.0.2:
-  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@^3.0.8:
-  version "3.0.8"
-  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-3.0.8.tgz#b6a20978c1b383477f5c11a529428b880bfe0f4d"
-  integrity sha512-KMWPF8wNWa+wzffE9247hlDB1c9DMMxhwIFzwRn7oNv5CU7auuJ3zKWv756F/9qqlEucC5jI8/3S8qdGKdVelw==
-  dependencies:
-    ansi-escape-sequences "^3.0.0"
-    array-back "^1.0.3"
-    feature-detect-es6 "^1.3.1"
-    table-layout "^0.3.0"
-    typical "^2.6.0"
-
-command-line-usage@^5.0.5:
-  version "5.0.5"
-  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-5.0.5.tgz#5f25933ffe6dedd983c635d38a21d7e623fda357"
-  integrity sha512-d8NrGylA5oCXSbGoKz05FkehDAzSmIm4K03S5VDh4d5lZAtTWfc3D1RuETtuQCn8129nYfJfDdF7P/lwcz1BlA==
-  dependencies:
-    array-back "^2.0.0"
-    chalk "^2.4.1"
-    table-layout "^0.4.3"
-    typical "^2.6.1"
-
 commander@^2.20.0, commander@^2.20.3:
   version "2.20.3"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
@@ -869,20 +505,6 @@
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
   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==
-
-crisper@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/crisper/-/crisper-2.1.1.tgz#4cc7321c3e90f3c5cbdc3503217f118fd7d5c51c"
-  integrity sha512-yxfj9nTbFunDASztAxVF8hCPwaZBvTjayNzG3YL/VVQfQaKBXX2+TM3p1xB1Pxd8RYeDQJkJIQRwM3FQSIa+pw==
-  dependencies:
-    command-line-args "^3.0.1"
-    command-line-usage "^3.0.8"
-    dom5 "^1.0.1"
-
 cross-spawn@^7.0.3:
   version "7.0.3"
   resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
@@ -892,12 +514,7 @@
     shebang-command "^2.0.0"
     which "^2.0.1"
 
-cssbeautify@^0.3.1:
-  version "0.3.1"
-  resolved "https://registry.yarnpkg.com/cssbeautify/-/cssbeautify-0.3.1.tgz#12dd1f734035c2e6faca67dcbdcef74e42811397"
-  integrity sha512-ljnSOCOiMbklF+dwPbpooyB78foId02vUrTDogWzu6ca2DCNB7Kc/BHEGBnYOlUYtwXvSW0mWTwaiO2pwFIoRg==
-
-debug@^2.2.0, debug@^2.6.8:
+debug@^2.2.0:
   version "2.6.9"
   resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
   integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
@@ -911,7 +528,7 @@
   dependencies:
     ms "^2.1.1"
 
-debug@^4.1.0, debug@^4.3.1:
+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==
@@ -925,47 +542,12 @@
   dependencies:
     mimic-response "^3.1.0"
 
-deep-extend@~0.4.1:
-  version "0.4.2"
-  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.2.tgz#48b699c27e334bf89f10892be432f6e4c7d34a7f"
-  integrity 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==
 
-detect-indent@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208"
-  integrity sha512-BDKtmHlOzwI7iRuEkhzsnPoi5ypEhWAJB5RvHWe1kMr06js3uK5B3734i3ui5Yd+wOJV1cpE4JnivPD283GU/A==
-  dependencies:
-    repeating "^2.0.0"
-
-doctrine@^2.0.2:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
-  integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==
-  dependencies:
-    esutils "^2.0.2"
-
-dom5@^1.0.1:
-  version "1.3.6"
-  resolved "https://registry.yarnpkg.com/dom5/-/dom5-1.3.6.tgz#a7088a9fc5f3b08dc9f6eda4c7abaeb241945e0d"
-  integrity sha512-mcW8C3hP6NR7PD2mpa6cLihu0ToVrsloG69a/4vZ8lbKrAApEVJi99O2vqd5G1gfnvmLHbGSo/LdHbWBwdF4Rw==
-  dependencies:
-    "@types/clone" "^0.1.29"
-    "@types/node" "^4.0.30"
-    "@types/parse5" "^0.0.31"
-    clone "^1.0.2"
-    parse5 "^1.4.1"
-
-dom5@^3.0.0, dom5@^3.0.1:
+dom5@^3.0.1:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/dom5/-/dom5-3.0.1.tgz#cdfc7331f376e284bf379e6ea054afc136702944"
   integrity sha512-JPFiouQIr16VQ4dX6i0+Hpbg3H2bMKPmZ+WZgBOSSvOPx9QHwwY8sPzeM2baUtViESYto6wC2nuZOMC/6gulcA==
@@ -993,7 +575,7 @@
   dependencies:
     es6-promise "^4.0.3"
 
-escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5:
+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==
@@ -1003,14 +585,6 @@
   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"
-  resolved "https://registry.yarnpkg.com/espree/-/espree-3.5.4.tgz#b0f447187c8a8bed944b815a660bddf5deb5d1a7"
-  integrity sha512-yAcIQxtmMiB/jL32dzEp2enBeidsB7xWPLNiw3IIkpVds1P+h7qF9YwJq1yUNzp2OKXgAprs4F61ih66UsoD1A==
-  dependencies:
-    acorn "^5.5.0"
-    acorn-jsx "^3.0.0"
-
 estree-walker@^0.6.1:
   version "0.6.1"
   resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.6.1.tgz#53049143f40c6eb918b23671d1fe3219f3a1b362"
@@ -1021,11 +595,6 @@
   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==
-
 fd-slicer@~1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e"
@@ -1033,28 +602,6 @@
   dependencies:
     pend "~1.2.0"
 
-feature-detect-es6@^1.3.1:
-  version "1.5.0"
-  resolved "https://registry.yarnpkg.com/feature-detect-es6/-/feature-detect-es6-1.5.0.tgz#a69bb7662c65f64f89f07eac5a461b649a1e0a00"
-  integrity sha512-DzWPIGzTnfp3/KK1d/YPfmgLqeDju9F2DQYBL35VusgSApcA7XGqVtXfR4ETOOFEzdFJ3J7zh0Gkk011TiA4uQ==
-  dependencies:
-    array-back "^1.0.4"
-
-find-replace@^1.0.2:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-1.0.3.tgz#b88e7364d2d9c959559f388c66670d6130441fa0"
-  integrity sha512-KrUnjzDCD9426YnCP56zGYy/eieTnhtK6Vn++j+JJzmlsWWwEkDnsyVF575spT6HJ6Ow9tlbT3TQTDsa+O4UWA==
-  dependencies:
-    array-back "^1.0.4"
-    test-value "^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"
-
 freeport@^1.0.4:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/freeport/-/freeport-1.0.5.tgz#255e8ab84170c33ba85d990e821ae5f4a1a9bc5d"
@@ -1099,16 +646,6 @@
     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==
-
-globals@^9.18.0:
-  version "9.18.0"
-  resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a"
-  integrity sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==
-
 google-protobuf@^3.6.1:
   version "3.20.1"
   resolved "https://registry.yarnpkg.com/google-protobuf/-/google-protobuf-3.20.1.tgz#1b255c2b59bcda7c399df46c65206aa3c7a0ce8b"
@@ -1131,18 +668,16 @@
     p-cancelable "^2.0.0"
     responselike "^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 sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==
-  dependencies:
-    ansi-regex "^2.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@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
@@ -1176,11 +711,6 @@
   resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
   integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
 
-indent@0.0.2:
-  version "0.0.2"
-  resolved "https://registry.yarnpkg.com/indent/-/indent-0.0.2.tgz#8c79f080190559b687034b84c7aefa97d5a911d9"
-  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"
@@ -1194,13 +724,6 @@
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
 
-invariant@^2.2.2:
-  version "2.2.4"
-  resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
-  integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
-  dependencies:
-    loose-envify "^1.0.0"
-
 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"
@@ -1208,11 +731,6 @@
   dependencies:
     has "^1.0.3"
 
-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-module@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
@@ -1225,54 +743,30 @@
   dependencies:
     "@types/estree" "*"
 
-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==
-
 isexe@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
   integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
 
-jest-worker@^24.9.0:
-  version "24.9.0"
-  resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-24.9.0.tgz#5dbfdb5b2d322e98567898238a9697bcce67b3e5"
-  integrity sha512-51PE4haMSXcHohnSMdM42anbvZANYTqMrr52tVKPqqsPJMzoP6FYYDVqahX/HrAoKEKz3uUPzSvKs9A3qR4iVw==
+jest-worker@^26.2.1:
+  version "26.6.2"
+  resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.6.2.tgz#7f72cbc4d643c365e27b9fd775f9d0eaa9c7a8ed"
+  integrity sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==
   dependencies:
+    "@types/node" "*"
     merge-stream "^2.0.0"
-    supports-color "^6.1.0"
+    supports-color "^7.0.0"
 
-"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
+js-tokens@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
   integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
 
-js-tokens@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"
-  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 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==
-
 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==
 
-jsonschema@^1.1.0:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/jsonschema/-/jsonschema-1.4.1.tgz#cc4c3f0077fb4542982973d8a083b6b34f482dab"
-  integrity sha512-S6cATIPVv1z0IlxdN+zUk5EPjkGCdnhN4wVSBlvoUO1tOLJootbo9CquNJmbIh4yikWHiUedhRYrNPn1arpEmQ==
-
 keyv@^4.0.0:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.3.0.tgz#b4352e0e4fe7c94111947d6738a6d3fe7903027c"
@@ -1294,11 +788,6 @@
     rimraf "^3.0.0"
     underscore "^1.8.3"
 
-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.mapvalues@^4.6.0:
   version "4.6.0"
   resolved "https://registry.yarnpkg.com/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz#1bafa5005de9dd6f4f26668c30ca37230cc9689c"
@@ -1309,17 +798,7 @@
   resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
   integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
 
-lodash.padend@^4.6.1:
-  version "4.6.1"
-  resolved "https://registry.yarnpkg.com/lodash.padend/-/lodash.padend-4.6.1.tgz#53ccba047d06e158d311f45da625f4e49e6f166e"
-  integrity 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 sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==
-
-lodash@4.17.21, lodash@^4.17.14, lodash@^4.17.4:
+lodash@4.17.21, lodash@^4.17.14:
   version "4.17.21"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
   integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@@ -1329,25 +808,11 @@
   resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28"
   integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==
 
-loose-envify@^1.0.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
-  integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
-  dependencies:
-    js-tokens "^3.0.0 || ^4.0.0"
-
 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==
 
-magic-string@^0.22.4:
-  version "0.22.5"
-  resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.22.5.tgz#8e9cf5afddf44385c1da5bc2a6a0dbd10b03657e"
-  integrity sha512-oreip9rJZkzvA8Qzk9HFs8fZGF/u7H/gtrE8EN6RjKJ9kh2HlC+yQ2QezifqTZfGyiuAV0dRv5a+y/8gBb1m9w==
-  dependencies:
-    vlq "^0.2.2"
-
 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"
@@ -1370,7 +835,7 @@
   resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9"
   integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==
 
-minimatch@^3.0.4, minimatch@^3.1.1:
+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==
@@ -1441,11 +906,6 @@
   dependencies:
     parse5 "^5.1.1"
 
-parse5@^1.4.1:
-  version "1.5.1"
-  resolved "https://registry.yarnpkg.com/parse5/-/parse5-1.5.1.tgz#9b7f3b0de32be78dc2401b17573ccaf0f6f59d94"
-  integrity sha512-w2jx/0tJzvgKwZa58sj2vAYq/S/K1QJfIB3cWYea/Iu1scFPDQQ3IQiVZTHWtRBwAjv2Yd7S/xeZf3XqLDb3bA==
-
 parse5@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608"
@@ -1461,11 +921,6 @@
   resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
   integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==
 
-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 sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==
-
 path-key@^3.1.0:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
@@ -1495,72 +950,6 @@
     xmlbuilder "8.2.2"
     xmldom "0.1.x"
 
-polymer-analyzer@^3.2.2:
-  version "3.2.4"
-  resolved "https://registry.yarnpkg.com/polymer-analyzer/-/polymer-analyzer-3.2.4.tgz#7d76356620a2328e8bc9e30e47069f9729260ca1"
-  integrity sha512-JmxUhMajTuC18tLXbTtu2+aN2x9bTX+4MvCD4IZKJ0rtAL8jWi1iRLfogpHJB4Ig9Dc8EEEuEYipLuzPFl3vqA==
-  dependencies:
-    "@babel/generator" "^7.0.0-beta.42"
-    "@babel/traverse" "^7.0.0-beta.42"
-    "@babel/types" "^7.0.0-beta.42"
-    "@types/babel-generator" "^6.25.1"
-    "@types/babel-traverse" "^6.25.2"
-    "@types/babel-types" "^6.25.1"
-    "@types/babylon" "^6.16.2"
-    "@types/chai-subset" "^1.3.0"
-    "@types/chalk" "^0.4.30"
-    "@types/clone" "^0.1.30"
-    "@types/cssbeautify" "^0.3.1"
-    "@types/doctrine" "^0.0.1"
-    "@types/is-windows" "^0.2.0"
-    "@types/minimatch" "^3.0.1"
-    "@types/parse5" "^2.2.34"
-    "@types/path-is-inside" "^1.0.0"
-    "@types/resolve" "0.0.6"
-    "@types/whatwg-url" "^6.4.0"
-    babylon "^7.0.0-beta.42"
-    cancel-token "^0.1.1"
-    chalk "^1.1.3"
-    clone "^2.0.0"
-    cssbeautify "^0.3.1"
-    doctrine "^2.0.2"
-    dom5 "^3.0.0"
-    indent "0.0.2"
-    is-windows "^1.0.2"
-    jsonschema "^1.1.0"
-    minimatch "^3.0.4"
-    parse5 "^4.0.0"
-    path-is-inside "^1.0.2"
-    resolve "^1.5.0"
-    shady-css-parser "^0.1.0"
-    stable "^0.1.6"
-    strip-indent "^2.0.0"
-    vscode-uri "=1.0.6"
-    whatwg-url "^6.4.0"
-
-polymer-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==
-  dependencies:
-    "@types/babel-generator" "^6.25.1"
-    "@types/babel-traverse" "^6.25.3"
-    babel-generator "^6.26.1"
-    babel-traverse "^6.26.0"
-    babel-types "^6.26.0"
-    clone "^2.1.0"
-    command-line-args "^5.0.2"
-    command-line-usage "^5.0.5"
-    dom5 "^3.0.0"
-    espree "^3.5.2"
-    magic-string "^0.22.4"
-    mkdirp "^0.5.1"
-    parse5 "^4.0.0"
-    polymer-analyzer "^3.2.2"
-    rollup "^1.3.0"
-    source-map "^0.5.6"
-    vscode-uri "=1.0.6"
-
 progress@2.0.3:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
@@ -1593,11 +982,6 @@
     end-of-stream "^1.1.0"
     once "^1.3.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:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
@@ -1624,29 +1008,12 @@
     string_decoder "^1.1.1"
     util-deprecate "^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=
-
-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==
-
-repeating@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda"
-  integrity sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=
-  dependencies:
-    is-finite "^1.0.0"
-
 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@^1.11.0, resolve@^1.11.1, resolve@^1.5.0:
+resolve@^1.11.0, resolve@^1.11.1:
   version "1.22.0"
   resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.0.tgz#5e0b8c67c15df57a89bdbabe603a002f21731198"
   integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==
@@ -1701,37 +1068,27 @@
     resolve "^1.11.1"
     rollup-pluginutils "^2.8.1"
 
-rollup-plugin-terser@^5.1.3:
-  version "5.3.1"
-  resolved "https://registry.yarnpkg.com/rollup-plugin-terser/-/rollup-plugin-terser-5.3.1.tgz#8c650062c22a8426c64268548957463bf981b413"
-  integrity sha512-1pkwkervMJQGFYvM9nscrUoncPwiKR/K+bHdjv6PFgRo3cgPHoRT83y2Aa3GvINj4539S15t/tpFPb775TDs6w==
+rollup-plugin-terser@^7.0.2:
+  version "7.0.2"
+  resolved "https://registry.yarnpkg.com/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz#e8fbba4869981b2dc35ae7e8a502d5c6c04d324d"
+  integrity sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==
   dependencies:
-    "@babel/code-frame" "^7.5.5"
-    jest-worker "^24.9.0"
-    rollup-pluginutils "^2.8.2"
+    "@babel/code-frame" "^7.10.4"
+    jest-worker "^26.2.1"
     serialize-javascript "^4.0.0"
-    terser "^4.6.2"
+    terser "^5.0.0"
 
-rollup-pluginutils@^2.8.1, rollup-pluginutils@^2.8.2:
+rollup-pluginutils@^2.8.1:
   version "2.8.2"
   resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz#72f2af0748b592364dbd3389e600e5a9444a351e"
   integrity sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==
   dependencies:
     estree-walker "^0.6.1"
 
-rollup@^1.3.0:
-  version "1.32.1"
-  resolved "https://registry.yarnpkg.com/rollup/-/rollup-1.32.1.tgz#4480e52d9d9e2ae4b46ba0d9ddeaf3163940f9c4"
-  integrity sha512-/2HA0Ec70TvQnXdzynFffkjA6XN+1e2pEv/uKS5Ulca40g2L7KuOE3riasHoNVHOsFD5KKZgDsMk1CP3Tw9s+A==
-  dependencies:
-    "@types/estree" "*"
-    "@types/node" "*"
-    acorn "^7.1.0"
-
-rollup@^2.3.4:
-  version "2.75.5"
-  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.75.5.tgz#7985c1962483235dd07966f09fdad5c5f89f16d0"
-  integrity sha512-JzNlJZDison3o2mOxVmb44Oz7t74EfSd1SQrplQk0wSaXV7uLQXtVdHbxlcT3w+8tZ1TL4r/eLfc7nAbz38BBA==
+rollup@^2.79.1:
+  version "2.79.1"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7"
+  integrity sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==
   optionalDependencies:
     fsevents "~2.3.2"
 
@@ -1770,11 +1127,6 @@
   dependencies:
     randombytes "^2.1.0"
 
-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==
-
 shebang-command@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
@@ -1795,7 +1147,7 @@
     buffer-from "^1.0.0"
     source-map "^0.6.0"
 
-source-map-support@~0.5.12:
+source-map-support@~0.5.20:
   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==
@@ -1803,26 +1155,21 @@
     buffer-from "^1.0.0"
     source-map "^0.6.0"
 
-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:
   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.2:
+  version "0.7.4"
+  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656"
+  integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==
+
 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==
-
 string_decoder@^1.1.1:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
@@ -1830,23 +1177,6 @@
   dependencies:
     safe-buffer "~5.2.0"
 
-strip-ansi@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf"
-  integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=
-  dependencies:
-    ansi-regex "^2.0.0"
-
-strip-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=
-
-supports-color@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7"
-  integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=
-
 supports-color@^5.3.0:
   version "5.5.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
@@ -1854,41 +1184,18 @@
   dependencies:
     has-flag "^3.0.0"
 
-supports-color@^6.1.0:
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3"
-  integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==
+supports-color@^7.0.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 "^3.0.0"
+    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@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-0.3.0.tgz#6ee20dc483db371b3e5c87f704ed2f7c799d2c9a"
-  integrity sha1-buINxIPbNxs+XIf3BO0vfHmdLJo=
-  dependencies:
-    array-back "^1.0.3"
-    core-js "^2.4.1"
-    deep-extend "~0.4.1"
-    feature-detect-es6 "^1.3.1"
-    typical "^2.6.0"
-    wordwrapjs "^2.0.0-0"
-
-table-layout@^0.4.3:
-  version "0.4.5"
-  resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-0.4.5.tgz#d906de6a25fa09c0c90d1d08ecd833ecedcb7378"
-  integrity sha512-zTvf0mcggrGeTe/2jJ6ECkJHAQPIYEwDoqsiqBjI24mvRmQbInK5jq33fyypaCBxX08hMkfmdOqj6haT33EqWw==
-  dependencies:
-    array-back "^2.0.0"
-    deep-extend "~0.6.0"
-    lodash.padend "^4.6.1"
-    typical "^2.6.1"
-    wordwrapjs "^3.0.0"
-
 tar-stream@2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287"
@@ -1900,44 +1207,24 @@
     inherits "^2.0.3"
     readable-stream "^3.1.1"
 
-terser@^4.6.2:
-  version "4.8.0"
-  resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.0.tgz#63056343d7c70bb29f3af665865a46fe03a0df17"
-  integrity sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==
+terser@^5.0.0:
+  version "5.22.0"
+  resolved "https://registry.yarnpkg.com/terser/-/terser-5.22.0.tgz#4f18103f84c5c9437aafb7a14918273310a8a49d"
+  integrity sha512-hHZVLgRA2z4NWcN6aS5rQDc+7Dcy58HOf2zbYwmFcQ+ua3h6eEFf5lIDKTzbWwlazPyOZsFQO8V80/IjVNExEw==
+  dependencies:
+    "@jridgewell/source-map" "^0.3.3"
+    acorn "^8.8.2"
+    commander "^2.20.0"
+    source-map-support "~0.5.20"
+
+terser@~5.8.0:
+  version "5.8.0"
+  resolved "https://registry.yarnpkg.com/terser/-/terser-5.8.0.tgz#c6d352f91aed85cc6171ccb5e84655b77521d947"
+  integrity sha512-f0JH+6yMpneYcRJN314lZrSwu9eKkUFEHLN/kNy8ceh8gaRiLgFPJqrB9HsXjhEGdv4e/ekjTOFxIlL6xlma8A==
   dependencies:
     commander "^2.20.0"
-    source-map "~0.6.1"
-    source-map-support "~0.5.12"
-
-test-value@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/test-value/-/test-value-2.1.0.tgz#11da6ff670f3471a73b625ca4f3fdcf7bb748291"
-  integrity sha1-Edpv9nDzRxpztiXKTz/c97t0gpE=
-  dependencies:
-    array-back "^1.0.3"
-    typical "^2.6.0"
-
-to-fast-properties@^1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47"
-  integrity sha1-uDVx+k2MJbguIxsG46MFXeTKGkc=
-
-to-fast-properties@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"
-  integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=
-
-tr46@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09"
-  integrity sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=
-  dependencies:
-    punycode "^2.1.0"
-
-trim-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=
+    source-map "~0.7.2"
+    source-map-support "~0.5.20"
 
 tslib@^1.8.1:
   version "1.14.1"
@@ -1951,20 +1238,10 @@
   dependencies:
     tslib "^1.8.1"
 
-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"
-  resolved "https://registry.yarnpkg.com/typical/-/typical-2.6.1.tgz#5c080e5d661cbbe38259d2e70a3c7253e873881d"
-  integrity sha1-XAgOXWYcu+OCWdLnCjxyU+hziB0=
-
-typical@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/typical/-/typical-4.0.0.tgz#cbeaff3b9d7ae1e2bbfaf5a4e6f11eccfde94fc4"
-  integrity sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==
+typescript@^4.9.5:
+  version "4.9.5"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
+  integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
 
 underscore@^1.8.3:
   version "1.13.4"
@@ -1976,16 +1253,6 @@
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
   integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
 
-vlq@^0.2.2:
-  version "0.2.3"
-  resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26"
-  integrity sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==
-
-vscode-uri@=1.0.6:
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-1.0.6.tgz#6b8f141b0bbc44ad7b07e94f82f168ac7608ad4d"
-  integrity sha512-sLI2L0uGov3wKVb9EB+vIQBl9tVP90nqRvxSoJ35vI3NjxE8jfsE5DSOhWgSunHSZmKS4OCi2jrtfxK7uyp2ww==
-
 wct-local@2.1.6:
   version "2.1.6"
   resolved "https://registry.yarnpkg.com/wct-local/-/wct-local-2.1.6.tgz#2d099c52996e77265d16e03a5d6d897b77ea9967"
@@ -2002,20 +1269,6 @@
     selenium-standalone "^6.7.0"
     which "^1.0.8"
 
-webidl-conversions@^4.0.2:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
-  integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==
-
-whatwg-url@^6.4.0:
-  version "6.5.0"
-  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-6.5.0.tgz#f2df02bff176fd65070df74ad5ccbb5a199965a8"
-  integrity sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==
-  dependencies:
-    lodash.sortby "^4.7.0"
-    tr46 "^1.0.1"
-    webidl-conversions "^4.0.2"
-
 which@^1.0.8:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
@@ -2030,24 +1283,6 @@
   dependencies:
     isexe "^2.0.0"
 
-wordwrapjs@^2.0.0-0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-2.0.0.tgz#ab55f695e6118da93858fdd70c053d1c5e01ac20"
-  integrity sha1-q1X2leYRjak4WP3XDAU9HF4BrCA=
-  dependencies:
-    array-back "^1.0.3"
-    feature-detect-es6 "^1.3.1"
-    reduce-flatten "^1.0.1"
-    typical "^2.6.0"
-
-wordwrapjs@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-3.0.0.tgz#c94c372894cadc6feb1a66bff64e1d9af92c5d1e"
-  integrity sha512-mO8XtqyPvykVCsrwj5MlOVWvSnCdT+C+QVbm6blradR7JExAhbkZ7hZ9A+9NUtwzSqrlUo9a67ws0EiILrvRpw==
-  dependencies:
-    reduce-flatten "^1.0.1"
-    typical "^2.6.1"
-
 wrappy@1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index dc6e6e0..3a5215f 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -1,10 +1,10 @@
 load("//tools/bzl:maven_jar.bzl", "maven_jar")
 
-GUAVA_VERSION = "30.1-jre"
+GUAVA_VERSION = "32.1.2-jre"
 
-GUAVA_BIN_SHA1 = "00d0c3ce2311c9e36e73228da25a6e99b2ab826f"
+GUAVA_BIN_SHA1 = "5e64ec7e056456bef3a4bc4c6fdaef71e8ab6318"
 
-GUAVA_TESTLIB_BIN_SHA1 = "798c3827308605cd69697d8f1596a1735d3ef6e2"
+GUAVA_TESTLIB_BIN_SHA1 = "c7a8a2c91b6809ff46373b1bc06185241801f6b5"
 
 GUAVA_DOC_URL = "https://google.github.io/guava/releases/" + GUAVA_VERSION + "/api/docs/"
 
@@ -135,8 +135,8 @@
 
     maven_jar(
         name = "error-prone-annotations",
-        artifact = "com.google.errorprone:error_prone_annotations:2.15.0",
-        sha1 = "38c8485a652f808c8c149150da4e5c2b0bd17f9a",
+        artifact = "com.google.errorprone:error_prone_annotations:2.22.0",
+        sha1 = "bfb9e4281a4cea34f0ec85b3acd47621cfab35b4",
     )
 
     FLOGGER_VERS = "0.7.4"
@@ -154,6 +154,12 @@
     )
 
     maven_jar(
+        name = "flogger-google-extensions",
+        artifact = "com.google.flogger:google-extensions:" + FLOGGER_VERS,
+        sha1 = "c49493bd815e3842b8406e21117119d560399977",
+    )
+
+    maven_jar(
         name = "flogger-system-backend",
         artifact = "com.google.flogger:flogger-system-backend:" + FLOGGER_VERS,
         sha1 = "4bee7ebbd97c63ca7fb17529aeb49a57b670d061",
@@ -171,31 +177,31 @@
         sha1 = GUAVA_TESTLIB_BIN_SHA1,
     )
 
-    GUICE_VERS = "5.0.1"
+    GUICE_VERS = "6.0.0"
 
     maven_jar(
         name = "guice-library",
         artifact = "com.google.inject:guice:" + GUICE_VERS,
-        sha1 = "0dae7556b441cada2b4f0a2314eb68e1ff423429",
+        sha1 = "9b422c69c4fa1ea95b2615444a94fede9b02fc40",
     )
 
     maven_jar(
         name = "guice-assistedinject",
         artifact = "com.google.inject.extensions:guice-assistedinject:" + GUICE_VERS,
-        sha1 = "62e02f2aceb7d90ba354584dacc018c1e94ff01c",
+        sha1 = "849d991e4adf998cb9877124fe74b063c88726cf",
     )
 
     maven_jar(
         name = "guice-servlet",
         artifact = "com.google.inject.extensions:guice-servlet:" + GUICE_VERS,
-        sha1 = "f527009d51f172a2e6937bfb55fcb827e2e2386b",
+        sha1 = "1a505f5f1a269e01946790e863178a5055de4fa0",
     )
 
     # Keep this version of Soy synchronized with the version used in Gitiles.
     maven_jar(
         name = "soy",
-        artifact = "com.google.template:soy:2021-02-01",
-        sha1 = "8e833744832ba88059205a1e30e0898f925d8cb5",
+        artifact = "com.google.template:soy:2024-01-30",
+        sha1 = "6e9ccb00926325c7a9293ed05a2eaf56ea15d60e",
     )
 
     # Test-only dependencies below.
@@ -243,36 +249,36 @@
         sha1 = "64cba89cf87c1d84cb8c81d06f0b9c482f10b4dc",
     )
 
-    LUCENE_VERS = "7.7.3"
+    LUCENE_VERS = "8.11.2"
 
     maven_jar(
         name = "lucene-core",
         artifact = "org.apache.lucene:lucene-core:" + LUCENE_VERS,
-        sha1 = "5faa5ae56f7599019fce6184accc6c968b7519e7",
+        sha1 = "57438c3f31e0e440de149294890eee88e030ea6d",
     )
 
     maven_jar(
         name = "lucene-analyzers-common",
         artifact = "org.apache.lucene:lucene-analyzers-common:" + LUCENE_VERS,
-        sha1 = "0a76cbf5e21bbbb0c2d6288b042450236248214e",
+        sha1 = "07a74c5c2dd082b08c644a9016bc6ff66c8f27cc",
     )
 
     maven_jar(
         name = "backward-codecs",
         artifact = "org.apache.lucene:lucene-backward-codecs:" + LUCENE_VERS,
-        sha1 = "40207d0dd023a0e2868a23dd87d72f1a3cdbb893",
+        sha1 = "a5d0f0db405d607cc13265819b8d2ef0c81c0819",
     )
 
     maven_jar(
         name = "lucene-misc",
         artifact = "org.apache.lucene:lucene-misc:" + LUCENE_VERS,
-        sha1 = "3aca078edf983059722fe61a81b7b7bd5ecdb222",
+        sha1 = "9c7204f923465a96a20ac9e49cdca0cfcde64851",
     )
 
     maven_jar(
         name = "lucene-queryparser",
         artifact = "org.apache.lucene:lucene-queryparser:" + LUCENE_VERS,
-        sha1 = "685fc6166d29eb3e3441ae066873bb442aa02df1",
+        sha1 = "1886e3a27a8d4a73eb8fad54ea93a160b099bc60",
     )
 
     # JGit's transitive dependencies
diff --git a/version.bzl b/version.bzl
index 1700d83..6e6e9d5 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.8.10-SNAPSHOT"
+GERRIT_VERSION = "3.9.8-SNAPSHOT"
diff --git a/yarn.lock b/yarn.lock
index e3e764b..e6e6566 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2,131 +2,250 @@
 # yarn lockfile v1
 
 
-"@babel/code-frame@^7.0.0":
-  version "7.14.5"
-  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.14.5.tgz#23b08d740e83f49c5e59945fbf1b43e80bbf4edb"
-  integrity sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw==
+"@75lb/deep-merge@^1.1.1":
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/@75lb/deep-merge/-/deep-merge-1.1.1.tgz#3b06155b90d34f5f8cc2107d796f1853ba02fd6d"
+  integrity sha512-xvgv6pkMGBA6GwdyJbNAnDmfAIR/DfWhrj9jgWh3TY7gRm3KO46x/GPjRg6wJ0nOepwqrNxFfojebh0Df4h4Tw==
   dependencies:
-    "@babel/highlight" "^7.14.5"
+    lodash.assignwith "^4.2.0"
+    typical "^7.1.1"
 
-"@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==
+"@aashutoshrathi/word-wrap@^1.2.3":
+  version "1.2.6"
+  resolved "https://registry.yarnpkg.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz#bd9154aec9983f77b3a034ecaa015c2e4201f6cf"
+  integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==
+
+"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.11":
+  version "7.22.13"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.13.tgz#e3c1c099402598483b7a8c46a721d1038803755e"
+  integrity sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==
   dependencies:
-    "@babel/highlight" "^7.18.6"
+    "@babel/highlight" "^7.22.13"
+    chalk "^2.4.2"
 
-"@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/helper-validator-identifier@^7.22.20":
+  version "7.22.20"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0"
+  integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==
 
-"@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==
+"@babel/highlight@^7.22.13":
+  version "7.22.20"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.20.tgz#4ca92b71d80554b01427815e06f2df965b9c1f54"
+  integrity sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==
   dependencies:
-    "@babel/helper-validator-identifier" "^7.14.5"
-    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"
+    "@babel/helper-validator-identifier" "^7.22.20"
+    chalk "^2.4.2"
     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"
-  integrity sha512-99catp6bHCaxr4sJ/DbTGgHS4+Rs2RVd2g7iOap6SLGPDknRK9ztKNsE/Fg6QhSeh1FGE5f6gHGQmvvn3I3xhw==
+  version "7.22.15"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.15.tgz#38f46494ccf6cf020bd4eed7124b425e83e523b8"
+  integrity sha512-T0O+aa+4w0u06iNmapipJXMV4HoUir03hpx3/YqXXhu9xim3w+dVphjFWl1OH8NbZHw5Lbm9k45drDkgq2VNNA==
   dependencies:
-    regenerator-runtime "^0.13.4"
+    regenerator-runtime "^0.14.0"
 
-"@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==
+"@bazel/concatjs@^5.8.1":
+  version "5.8.1"
+  resolved "https://registry.yarnpkg.com/@bazel/concatjs/-/concatjs-5.8.1.tgz#dd20882429e382cae79c08cbd3238dfc680d2d67"
+  integrity sha512-TkARsNUxgi3bjFeGwIGlffmQglNhuR9qK9uE7uKhdBZvQE5caAWVCjYiMTzo3viKDhwKn5QNRcHY5huuJMVFfA==
   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==
+"@bazel/rollup@^5.8.1":
+  version "5.8.1"
+  resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-5.8.1.tgz#ee876c35595b456f700d258385412e4f0dd57c15"
+  integrity sha512-Ys+UWbRp1TY2j+z15N+SZgID/nuqAtJTgJDsz0NZVjm8F8KzmgXxLDnBb/cUKFVk83pNOAi84G/bq1tINjMSNA==
   dependencies:
-    "@bazel/worker" "5.5.0"
+    "@bazel/worker" "5.8.1"
 
-"@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/terser@^5.8.1":
+  version "5.8.1"
+  resolved "https://registry.yarnpkg.com/@bazel/terser/-/terser-5.8.1.tgz#729a0ec6dcc83e99c4f6d3f2bebb0ff254c10c48"
+  integrity sha512-TPjSDhw1pSZt9P2hd/22IJwl8KCZiJL+u2gB5mghBTCFDVdC5Dgsx135pFtvlqc6LjjOvd3s6dzcQr0YJo2HSg==
 
-"@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==
+"@bazel/typescript@^5.8.1":
+  version "5.8.1"
+  resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-5.8.1.tgz#74a76af434fad7930893cf3e98b4cc201e52dc65"
+  integrity sha512-NAJ8WQHZL1WE1YmRoCrq/1hhG15Mvy/viWh6TkvFnBeEhNUiQUsA5GYyhU1ztnBIYW03nATO3vwhAEfO7Q0U5g==
   dependencies:
-    "@bazel/worker" "5.5.0"
-    protobufjs "6.8.8"
+    "@bazel/worker" "5.8.1"
     semver "5.6.0"
     source-map-support "0.5.9"
     tsutils "3.21.0"
 
-"@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==
+"@bazel/worker@5.8.1":
+  version "5.8.1"
+  resolved "https://registry.yarnpkg.com/@bazel/worker/-/worker-5.8.1.tgz#65af7a70dd2f1aaedd6c19330abd9a198f96e7b2"
+  integrity sha512-GMyZSNW3F34f9GjbJqvs1aHyed5BNrNeiDzNJhC1fIizo/UeBM21oBBONIYLBDoBtq936U85VyPZ76JaP/83hw==
   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==
+"@es-joy/jsdoccomment@~0.39.4":
+  version "0.39.4"
+  resolved "https://registry.yarnpkg.com/@es-joy/jsdoccomment/-/jsdoccomment-0.39.4.tgz#6b8a62e9b3077027837728818d3c4389a898b392"
+  integrity sha512-Jvw915fjqQct445+yron7Dufix9A+m9j1fCJYlCo1FWlRvTxa3pjJelxdSTdaLWcTwRU6vbL+NYjO4YuNIS5Qg==
   dependencies:
     comment-parser "1.3.1"
-    esquery "^1.4.0"
-    jsdoc-type-pratt-parser "~3.1.0"
+    esquery "^1.5.0"
+    jsdoc-type-pratt-parser "~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==
+"@esbuild/android-arm64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz#bafb75234a5d3d1b690e7c2956a599345e84a2fd"
+  integrity sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==
 
-"@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==
+"@esbuild/android-arm@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.17.19.tgz#5898f7832c2298bc7d0ab53701c57beb74d78b4d"
+  integrity sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==
+
+"@esbuild/android-x64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.17.19.tgz#658368ef92067866d95fb268719f98f363d13ae1"
+  integrity sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==
+
+"@esbuild/darwin-arm64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz#584c34c5991b95d4d48d333300b1a4e2ff7be276"
+  integrity sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==
+
+"@esbuild/darwin-x64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz#7751d236dfe6ce136cce343dce69f52d76b7f6cb"
+  integrity sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==
+
+"@esbuild/freebsd-arm64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz#cacd171665dd1d500f45c167d50c6b7e539d5fd2"
+  integrity sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==
+
+"@esbuild/freebsd-x64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz#0769456eee2a08b8d925d7c00b79e861cb3162e4"
+  integrity sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==
+
+"@esbuild/linux-arm64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz#38e162ecb723862c6be1c27d6389f48960b68edb"
+  integrity sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==
+
+"@esbuild/linux-arm@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz#1a2cd399c50040184a805174a6d89097d9d1559a"
+  integrity sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==
+
+"@esbuild/linux-ia32@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz#e28c25266b036ce1cabca3c30155222841dc035a"
+  integrity sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==
+
+"@esbuild/linux-loong64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz#0f887b8bb3f90658d1a0117283e55dbd4c9dcf72"
+  integrity sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==
+
+"@esbuild/linux-mips64el@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz#f5d2a0b8047ea9a5d9f592a178ea054053a70289"
+  integrity sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==
+
+"@esbuild/linux-ppc64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz#876590e3acbd9fa7f57a2c7d86f83717dbbac8c7"
+  integrity sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==
+
+"@esbuild/linux-riscv64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz#7f49373df463cd9f41dc34f9b2262d771688bf09"
+  integrity sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==
+
+"@esbuild/linux-s390x@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz#e2afd1afcaf63afe2c7d9ceacd28ec57c77f8829"
+  integrity sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==
+
+"@esbuild/linux-x64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz#8a0e9738b1635f0c53389e515ae83826dec22aa4"
+  integrity sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==
+
+"@esbuild/netbsd-x64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz#c29fb2453c6b7ddef9a35e2c18b37bda1ae5c462"
+  integrity sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==
+
+"@esbuild/openbsd-x64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz#95e75a391403cb10297280d524d66ce04c920691"
+  integrity sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==
+
+"@esbuild/sunos-x64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz#722eaf057b83c2575937d3ffe5aeb16540da7273"
+  integrity sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==
+
+"@esbuild/win32-arm64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz#9aa9dc074399288bdcdd283443e9aeb6b9552b6f"
+  integrity sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==
+
+"@esbuild/win32-ia32@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz#95ad43c62ad62485e210f6299c7b2571e48d2b03"
+  integrity sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==
+
+"@esbuild/win32-x64@0.17.19":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz#8cfaf2ff603e9aabb910e9c0558c26cf32744061"
+  integrity sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==
+
+"@eslint-community/eslint-utils@^4.2.0":
+  version "4.4.0"
+  resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59"
+  integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==
+  dependencies:
+    eslint-visitor-keys "^3.3.0"
+
+"@eslint-community/regexpp@^4.4.0", "@eslint-community/regexpp@^4.6.1":
+  version "4.8.1"
+  resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.8.1.tgz#8c4bb756cc2aa7eaf13cfa5e69c83afb3260c20c"
+  integrity sha512-PWiOzLIUAjN/w5K17PoF4n6sKBw0gqLHPhywmYHP4t1VFQQVYeb1yWsJwnMVEMl3tUHME7X/SJPZLmtG7XBDxQ==
+
+"@eslint/eslintrc@^2.1.2":
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.2.tgz#c6936b4b328c64496692f76944e755738be62396"
+  integrity sha512-+wvgpDsrB1YqAMdEUCcnTlpfVBH7Vqn6A/NT3D8WVXFIaKMlErPIZT3oCIAVCOtarRpMtelZLqJeU3t7WY6X6g==
   dependencies:
     ajv "^6.12.4"
     debug "^4.3.2"
-    espree "^9.3.2"
-    globals "^13.15.0"
+    espree "^9.6.0"
+    globals "^13.19.0"
     ignore "^5.2.0"
     import-fresh "^3.2.1"
     js-yaml "^4.1.0"
     minimatch "^3.1.2"
     strip-json-comments "^3.1.1"
 
-"@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==
+"@eslint/js@8.49.0":
+  version "8.49.0"
+  resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.49.0.tgz#86f79756004a97fa4df866835093f1df3d03c333"
+  integrity sha512-1S8uAY/MTJqVx0SC4epBq+N2yhuwtNwLbJYNZyhL2pO1ZVKn5HFXav5T41Ryzy9K9V7ZId2JB2oy/W4aCd9/2w==
+
+"@humanwhocodes/config-array@^0.11.11":
+  version "0.11.11"
+  resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.11.tgz#88a04c570dbbc7dd943e4712429c3df09bc32844"
+  integrity sha512-N2brEuAadi0CcdeMXUkhbZB84eskAc8MEX1By6qEchoVywSgXPIjou4rYsl0V3Hj0ZnuGycGCjdNgockbzeWNA==
   dependencies:
     "@humanwhocodes/object-schema" "^1.2.1"
     debug "^4.1.1"
-    minimatch "^3.0.4"
+    minimatch "^3.0.5"
+
+"@humanwhocodes/module-importer@^1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c"
+  integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==
 
 "@humanwhocodes/object-schema@^1.2.1":
   version "1.2.1"
@@ -171,7 +290,7 @@
   resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b"
   integrity sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==
 
-"@nodelib/fs.walk@^1.2.3":
+"@nodelib/fs.walk@^1.2.3", "@nodelib/fs.walk@^1.2.8":
   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==
@@ -182,7 +301,7 @@
 "@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"
@@ -197,12 +316,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"
@@ -210,27 +329,27 @@
 "@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/plugin-node-resolve@^13.0.4":
   version "13.3.0"
@@ -253,18 +372,6 @@
     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"
-  integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==
-
-"@szmarczak/http-timer@^1.1.2":
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421"
-  integrity sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==
-  dependencies:
-    defer-to-connect "^1.0.1"
-
 "@types/accepts@*":
   version "1.3.5"
   resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575"
@@ -273,34 +380,34 @@
     "@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==
+  version "1.19.3"
+  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.3.tgz#fb558014374f7d9e56c8f34bab2042a3a07d25cd"
+  integrity sha512-oyl4jvAfTGX9Bt6Or4H9ni1Z447/tQuxnZsytsCaExKlmJiU8sFgnIBRzJUpKwB5eWn9HuBYlUlVA74q/yN0eQ==
   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==
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/@types/command-line-args/-/command-line-args-5.2.1.tgz#233bd1ba687e84ecbec0388e09f9ec9ebf63c55b"
+  integrity sha512-U2OcmS2tj36Yceu+mRuPyUV0ILfau/h5onStcSCzqTENsq0sBiAp2TmaXu1k8fY4McLcPKSYl9FRzn2hx5bI+w==
 
 "@types/connect@*":
-  version "3.4.35"
-  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1"
-  integrity sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==
+  version "3.4.36"
+  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.36.tgz#e511558c15a39cb29bd5357eebb57bd1459cd1ab"
+  integrity sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==
   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==
+  version "0.5.6"
+  resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.6.tgz#0f5fa03609f308a7a1a57e0b0afe4b95f1d19740"
+  integrity sha512-GmShTb4qA9+HMPPaV2+Up8tJafgi38geFi7vL4qAM7k8BwjoelgHZqEUKJZLvughUw22h6vD/wvwN4IUCaWpDA==
 
 "@types/cookies@*":
-  version "0.7.7"
-  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.7.tgz#7a92453d1d16389c05a5301eef566f34946cfd81"
-  integrity sha512-h7BcvPUogWbKCzBR2lY4oqaZbO3jXZksexYJVFvkrFeLgbZjQkU4x8pRq6eg2MHXQhY0McQdqmmsxRWlVAHooA==
+  version "0.7.8"
+  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.8.tgz#16fccd6d58513a9833c527701a90cc96d216bc18"
+  integrity sha512-y6KhF1GtsLERUpqOV+qZJrjUGzc0GE6UTa0b5Z/LZ7Nm2mKSdCXmS6Kdnl7fctPNnMSouHjxqEWI12/YqQfk5w==
   dependencies:
     "@types/connect" "*"
     "@types/express" "*"
@@ -312,22 +419,23 @@
   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==
+"@types/express-serve-static-core@^4.17.33":
+  version "4.17.36"
+  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.36.tgz#baa9022119bdc05a4adfe740ffc97b5f9360e545"
+  integrity sha512-zbivROJ0ZqLAtMzgzIUC4oNqDG9iF0lSsAqpOD9kbs5xcIM3dTiyuHvBc7R8MtWBp3AAWGaovJa+wzWPjLYW7Q==
   dependencies:
     "@types/node" "*"
     "@types/qs" "*"
     "@types/range-parser" "*"
+    "@types/send" "*"
 
 "@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==
+  version "4.17.17"
+  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.17.tgz#01d5437f6ef9cfa8668e616e13c2f2ac9a491ae4"
+  integrity sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==
   dependencies:
     "@types/body-parser" "*"
-    "@types/express-serve-static-core" "^4.17.18"
+    "@types/express-serve-static-core" "^4.17.33"
     "@types/qs" "*"
     "@types/serve-static" "*"
 
@@ -337,36 +445,36 @@
   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==
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.2.tgz#a86e00bbde8950364f8e7846687259ffcd96e8c2"
+  integrity sha512-lPG6KlZs88gef6aD85z3HNkztpj7w2R7HmR3gygjfXCQmsLloWNARFkMuzKiiY8FGdh1XDpgBdrSf4aKDiA7Kg==
 
 "@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==
+  version "7.0.13"
+  resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.13.tgz#02c24f4363176d2d18fc8b70b9f3c54aba178a85"
+  integrity sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ==
 
 "@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=
+  integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
 
 "@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==
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.3.tgz#2286b16ef71d8dea74dab00902ef419a54341bfe"
+  integrity sha512-tfzBBb7OV2PbUfKbG6zRE5UbmtdLVCKT/XT364Z9ny6pXNbd9GnIB6aFYpq2A5lZ6mq9bhXgK6h5MFGNwhMmuQ==
 
 "@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==
+  version "3.2.6"
+  resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.6.tgz#17a077786d0ac5eee04c37a7d6c207b3252f6de9"
+  integrity sha512-PHiciWxH3NRyAaxUdEDE1NIZNfvhgtPlsdkjRPazHC6weqt90Jr0uLhIQs+SDwC8HQ/jnA7UQP6xOqGFB7ugWw==
   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==
+  version "2.13.9"
+  resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.9.tgz#8d989ac17d7f033475fbe34c4f906c9287c2041a"
+  integrity sha512-tPX3cN1dGrMn+sjCDEiQqXH2AqlPoPd594S/8zxwUm/ZbPsQXKqHPUypr2gjCPhHUc+nDJLduhh5lXI/1olnGQ==
   dependencies:
     "@types/accepts" "*"
     "@types/content-disposition" "*"
@@ -378,24 +486,29 @@
     "@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==
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.2.tgz#b74129719fc8d11c01868010082d483b7545591a"
+  integrity sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==
 
 "@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/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/minimist@^1.2.0":
   version "1.2.2"
   resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c"
   integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==
 
 "@types/node@*":
-  version "18.7.2"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.2.tgz#22306626110c459aedd2cdf131c749ec781e3b34"
-  integrity sha512-ce7MIiaYWCFv6A83oEultwhBXb22fxwNOQf5DIxWA4WXvDQ7K+L0fbWl/YOfCzlR5B/uFkSnVBhPcOfOECcWvA==
+  version "20.6.3"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-20.6.3.tgz#5b763b321cd3b80f6b8dde7a37e1a77ff9358dd9"
+  integrity sha512-HksnYH4Ljr4VQgEy2lTStbCKv/P590tmPe5HqOnv9Gprffgv5WXAY+Y5Gqniu0GGqeTCUdBnzC3QSrzPkBkAMA==
 
 "@types/node@^10.1.0":
   version "10.17.60"
@@ -407,10 +520,10 @@
   resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301"
   integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==
 
-"@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/page@^1.11.6":
+  version "1.11.6"
+  resolved "https://registry.yarnpkg.com/@types/page/-/page-1.11.6.tgz#d531dc26067ca6a52e785db54c65b9095b9d5b84"
+  integrity sha512-oOJysLQSd7rY3aqnEuPui0zJGQDQbwuwclwn9FQQVVome/U4oF2XK1SDp/1kWXhVlohey0zVr2LdV17y5fdLhg==
 
 "@types/parse5@^6.0.1":
   version "6.0.3"
@@ -418,9 +531,9 @@
   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==
+  version "6.9.8"
+  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.8.tgz#f2a7de3c107b89b441e071d5472e6b726b4adf45"
+  integrity sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==
 
 "@types/range-parser@*":
   version "1.2.4"
@@ -434,11 +547,25 @@
   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==
+"@types/semver@^7.3.12":
+  version "7.5.2"
+  resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.2.tgz#31f6eec1ed7ec23f4f05608d3a2d381df041f564"
+  integrity sha512-7aqorHYgdNO4DM36stTiGO3DvKoex9TQRwsJU6vMaFGyqpBA1MNZkz+PG3gaNUPpTAOYhT1WR7M1JyA3fbS9Cw==
+
+"@types/send@*":
+  version "0.17.1"
+  resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.1.tgz#ed4932b8a2a805f1fe362a70f4e62d0ac994e301"
+  integrity sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==
   dependencies:
+    "@types/mime" "^1"
+    "@types/node" "*"
+
+"@types/serve-static@*":
+  version "1.15.2"
+  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.2.tgz#3e5419ecd1e40e7405d34093f10befb43f63381a"
+  integrity sha512-J2LqtvFYCzaj8pVYKw8klQXrLLk7TBZmQ4ShlcdkELFKGwGMfevMLneMMRkMgZxotOD9wg497LpC7O8PcvAmfw==
+  dependencies:
+    "@types/http-errors" "*"
     "@types/mime" "*"
     "@types/node" "*"
 
@@ -449,84 +576,88 @@
   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==
+"@typescript-eslint/eslint-plugin@^4.2.0", "@typescript-eslint/eslint-plugin@^5.62.0":
+  version "5.62.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz#aeef0328d172b9e37d9bab6dbc13b87ed88977db"
+  integrity sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==
   dependencies:
-    "@typescript-eslint/scope-manager" "5.27.0"
-    "@typescript-eslint/type-utils" "5.27.0"
-    "@typescript-eslint/utils" "5.27.0"
+    "@eslint-community/regexpp" "^4.4.0"
+    "@typescript-eslint/scope-manager" "5.62.0"
+    "@typescript-eslint/type-utils" "5.62.0"
+    "@typescript-eslint/utils" "5.62.0"
     debug "^4.3.4"
-    functional-red-black-tree "^1.0.1"
+    graphemer "^1.4.0"
     ignore "^5.2.0"
-    regexpp "^3.2.0"
+    natural-compare-lite "^1.4.0"
     semver "^7.3.7"
     tsutils "^3.21.0"
 
-"@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==
+"@typescript-eslint/parser@^4.2.0", "@typescript-eslint/parser@^5.62.0":
+  version "5.62.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.62.0.tgz#1b63d082d849a2fcae8a569248fbe2ee1b8a56c7"
+  integrity sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==
   dependencies:
-    "@typescript-eslint/scope-manager" "5.27.0"
-    "@typescript-eslint/types" "5.27.0"
-    "@typescript-eslint/typescript-estree" "5.27.0"
+    "@typescript-eslint/scope-manager" "5.62.0"
+    "@typescript-eslint/types" "5.62.0"
+    "@typescript-eslint/typescript-estree" "5.62.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==
+"@typescript-eslint/scope-manager@5.62.0":
+  version "5.62.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz#d9457ccc6a0b8d6b37d0eb252a23022478c5460c"
+  integrity sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==
   dependencies:
-    "@typescript-eslint/types" "5.27.0"
-    "@typescript-eslint/visitor-keys" "5.27.0"
+    "@typescript-eslint/types" "5.62.0"
+    "@typescript-eslint/visitor-keys" "5.62.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==
+"@typescript-eslint/type-utils@5.62.0":
+  version "5.62.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz#286f0389c41681376cdad96b309cedd17d70346a"
+  integrity sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==
   dependencies:
-    "@typescript-eslint/utils" "5.27.0"
+    "@typescript-eslint/typescript-estree" "5.62.0"
+    "@typescript-eslint/utils" "5.62.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/types@5.62.0":
+  version "5.62.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.62.0.tgz#258607e60effa309f067608931c3df6fed41fd2f"
+  integrity sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==
 
-"@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==
+"@typescript-eslint/typescript-estree@5.62.0":
+  version "5.62.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz#7d17794b77fabcac615d6a48fb143330d962eb9b"
+  integrity sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==
   dependencies:
-    "@typescript-eslint/types" "5.27.0"
-    "@typescript-eslint/visitor-keys" "5.27.0"
+    "@typescript-eslint/types" "5.62.0"
+    "@typescript-eslint/visitor-keys" "5.62.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==
+"@typescript-eslint/utils@5.62.0":
+  version "5.62.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.62.0.tgz#141e809c71636e4a75daa39faed2fb5f4b10df86"
+  integrity sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==
   dependencies:
+    "@eslint-community/eslint-utils" "^4.2.0"
     "@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"
+    "@types/semver" "^7.3.12"
+    "@typescript-eslint/scope-manager" "5.62.0"
+    "@typescript-eslint/types" "5.62.0"
+    "@typescript-eslint/typescript-estree" "5.62.0"
     eslint-scope "^5.1.1"
-    eslint-utils "^3.0.0"
+    semver "^7.3.7"
 
-"@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==
+"@typescript-eslint/visitor-keys@5.62.0":
+  version "5.62.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz#2174011917ce582875954ffe2f6912d5931e353e"
+  integrity sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==
   dependencies:
-    "@typescript-eslint/types" "5.27.0"
+    "@typescript-eslint/types" "5.62.0"
     eslint-visitor-keys "^3.3.0"
 
 "@web/config-loader@^0.1.3":
@@ -536,20 +667,20 @@
   dependencies:
     semver "^7.3.4"
 
-"@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==
+"@web/dev-server-core@^0.4.1":
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-core/-/dev-server-core-0.4.1.tgz#803faff45281ee296d0dda02dfdd905c330db4d8"
+  integrity sha512-KdYwejXZwIZvb6tYMCqU7yBiEOPfKLQ3V9ezqqEz8DA9V9R3oQWaowckvCpFB9IxxPfS/P8/59OkdzGKQjcIUw==
   dependencies:
     "@types/koa" "^2.11.6"
     "@types/ws" "^7.4.0"
-    "@web/parse5-utils" "^1.2.0"
+    "@web/parse5-utils" "^1.3.1"
     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"
+    isbinaryfile "^5.0.0"
     koa "^2.13.0"
     koa-etag "^4.0.0"
     koa-send "^5.0.1"
@@ -560,53 +691,53 @@
     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==
+"@web/dev-server-esbuild@^0.3.6":
+  version "0.3.6"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-esbuild/-/dev-server-esbuild-0.3.6.tgz#838100894937443b96bfc4266c7795d27ed4afac"
+  integrity sha512-VDcZOzvmbg/z/8Q54hHqFwt9U4cacQJZxgS8YXAvyFuG85HAJ/Q55P7Tr++1NlRS8wQEos6QK2ERUWNjEVOhqQ==
   dependencies:
     "@mdn/browser-compat-data" "^4.0.0"
-    "@web/dev-server-core" "^0.3.19"
-    esbuild "^0.12 || ^0.13 || ^0.14"
+    "@web/dev-server-core" "^0.4.1"
+    esbuild "^0.16 || ^0.17"
     parse5 "^6.0.1"
-    ua-parser-js "^1.0.2"
+    ua-parser-js "^1.0.33"
 
-"@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==
+"@web/dev-server-rollup@^0.4.1":
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/@web/dev-server-rollup/-/dev-server-rollup-0.4.1.tgz#3c6606bac8e497498b5b47bf9e0c544c321b38ef"
+  integrity sha512-Ebsv7Ovd9MufeH3exvikBJ7GmrZA5OmHnOgaiHcwMJ2eQBJA5/I+/CbRjsLX97ICj/ZwZG//p2ITRz8W3UfSqg==
   dependencies:
     "@rollup/plugin-node-resolve" "^13.0.4"
-    "@web/dev-server-core" "^0.3.19"
+    "@web/dev-server-core" "^0.4.1"
     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==
+"@web/dev-server@^0.1.38":
+  version "0.1.38"
+  resolved "https://registry.yarnpkg.com/@web/dev-server/-/dev-server-0.1.38.tgz#d755092d66aeb923c546237a6c460439ea3ddd29"
+  integrity sha512-WUq7Zi8KeJ5/UZmmpZ+kzUpUlFlMP/rcreJKYg9Lxiz998KYl4G5Rv24akX0piTuqXG7r6h+zszg8V/hdzjCoA==
   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"
+    "@web/dev-server-core" "^0.4.1"
+    "@web/dev-server-rollup" "^0.4.1"
     camelcase "^6.2.0"
     command-line-args "^5.1.1"
-    command-line-usage "^6.1.1"
+    command-line-usage "^7.0.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"
+    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==
+"@web/parse5-utils@^1.3.1":
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/@web/parse5-utils/-/parse5-utils-1.3.1.tgz#6727be4d7875a9ecb96a5b3003bd271da763f8b4"
+  integrity sha512-haCgDchZrAOB9EhBJ5XqiIjBMsS/exsM5Ru7sCSyNkXVEJWskyyKuKMFk66BonnIGMPpDtqDrTUfYEis5Zi3XA==
   dependencies:
     "@types/parse5" "^6.0.1"
     parse5 "^6.0.1"
@@ -624,12 +755,12 @@
   resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
   integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
 
-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==
+acorn@^8.9.0:
+  version "8.10.0"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.10.0.tgz#8be5b3907a67221a81ab23c7889c4c5526b62ec5"
+  integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==
 
-ajv@^6.10.0, ajv@^6.12.4:
+ajv@^6.12.4:
   version "6.12.6"
   resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
   integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==
@@ -639,13 +770,6 @@
     json-schema-traverse "^0.4.1"
     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"
-  integrity sha512-ZpClVKqXN3RGBmKibdfWzqCY4lnjEuoNzU5T0oEFpfd/z5qJHVarukridD4juLO2FXMiwUQxr9WqQtaYa8XRYw==
-  dependencies:
-    string-width "^3.0.0"
-
 ansi-escapes@^4.2.1:
   version "4.3.2"
   resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
@@ -653,16 +777,6 @@
   dependencies:
     type-fest "^0.21.3"
 
-ansi-regex@^4.1.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997"
-  integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==
-
-ansi-regex@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75"
-  integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==
-
 ansi-regex@^5.0.1:
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
@@ -683,13 +797,18 @@
     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==
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
+  integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
   dependencies:
     normalize-path "^3.0.0"
     picomatch "^2.0.4"
 
+are-docs-informative@^0.0.2:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/are-docs-informative/-/are-docs-informative-0.0.2.tgz#387f0e93f5d45280373d387a59d34c96db321963"
+  integrity sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==
+
 argparse@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
@@ -698,7 +817,7 @@
 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=
+  integrity sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==
 
 arr-flatten@^1.1.0:
   version "1.1.0"
@@ -708,27 +827,35 @@
 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=
+  integrity sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==
 
 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-back@^6.2.2:
+  version "6.2.2"
+  resolved "https://registry.yarnpkg.com/array-back/-/array-back-6.2.2.tgz#f567d99e9af88a6d3d2f9dfcc21db6f9ba9fd157"
+  integrity sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==
 
-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==
+array-buffer-byte-length@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz#fabe8bc193fea865f317fe7807085ee0dee5aead"
+  integrity sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==
   dependencies:
     call-bind "^1.0.2"
-    define-properties "^1.1.4"
-    es-abstract "^1.19.5"
-    get-intrinsic "^1.1.1"
+    is-array-buffer "^3.0.1"
+
+array-includes@^3.1.6:
+  version "3.1.7"
+  resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.7.tgz#8cd2e01b26f7a3086cbc87271593fe921c62abda"
+  integrity sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==
+  dependencies:
+    call-bind "^1.0.2"
+    define-properties "^1.2.0"
+    es-abstract "^1.22.1"
+    get-intrinsic "^1.2.1"
     is-string "^1.0.7"
 
 array-union@^2.1.0:
@@ -739,27 +866,61 @@
 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=
+  integrity sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ==
 
-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==
+array.prototype.findlastindex@^1.2.2:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz#b37598438f97b579166940814e2c0493a4f50207"
+  integrity sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==
   dependencies:
     call-bind "^1.0.2"
-    define-properties "^1.1.3"
-    es-abstract "^1.19.2"
+    define-properties "^1.2.0"
+    es-abstract "^1.22.1"
     es-shim-unscopables "^1.0.0"
+    get-intrinsic "^1.2.1"
+
+array.prototype.flat@^1.3.1:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz#1476217df8cff17d72ee8f3ba06738db5b387d18"
+  integrity sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==
+  dependencies:
+    call-bind "^1.0.2"
+    define-properties "^1.2.0"
+    es-abstract "^1.22.1"
+    es-shim-unscopables "^1.0.0"
+
+array.prototype.flatmap@^1.3.1:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz#c9a7c6831db8e719d6ce639190146c24bbd3e527"
+  integrity sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==
+  dependencies:
+    call-bind "^1.0.2"
+    define-properties "^1.2.0"
+    es-abstract "^1.22.1"
+    es-shim-unscopables "^1.0.0"
+
+arraybuffer.prototype.slice@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz#98bd561953e3e74bb34938e77647179dfe6e9f12"
+  integrity sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==
+  dependencies:
+    array-buffer-byte-length "^1.0.0"
+    call-bind "^1.0.2"
+    define-properties "^1.2.0"
+    es-abstract "^1.22.1"
+    get-intrinsic "^1.2.1"
+    is-array-buffer "^3.0.2"
+    is-shared-array-buffer "^1.0.2"
 
 arrify@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
-  integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=
+  integrity sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==
 
 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=
+  integrity sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==
 
 async@^2.6.4:
   version "2.6.4"
@@ -773,6 +934,11 @@
   resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
   integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
 
+available-typed-arrays@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7"
+  integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==
+
 balanced-match@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
@@ -796,20 +962,6 @@
   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"
-  resolved "https://registry.yarnpkg.com/boxen/-/boxen-5.0.1.tgz#657528bdd3f59a772b8279b831f27ec2c744664b"
-  integrity sha512-49VBlw+PrWEF51aCmy7QIteYPIFZxSpvqBdP/2itCPPlJ49kj9zg/XPRFrdkne2W+CfwXUls8exMvu1RysZpKA==
-  dependencies:
-    ansi-align "^3.0.0"
-    camelcase "^6.2.0"
-    chalk "^4.1.0"
-    cli-boxes "^2.2.1"
-    string-width "^4.2.0"
-    type-fest "^0.20.2"
-    widest-line "^3.1.0"
-    wrap-ansi "^7.0.0"
-
 brace-expansion@^1.1.7:
   version "1.1.11"
   resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
@@ -834,7 +986,7 @@
     split-string "^3.0.2"
     to-regex "^3.0.1"
 
-braces@^3.0.1, braces@~3.0.2:
+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==
@@ -874,19 +1026,6 @@
     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"
-  integrity sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==
-  dependencies:
-    clone-response "^1.0.2"
-    get-stream "^5.1.0"
-    http-cache-semantics "^4.0.0"
-    keyv "^3.0.0"
-    lowercase-keys "^2.0.0"
-    normalize-url "^4.1.0"
-    responselike "^1.0.2"
-
 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"
@@ -896,9 +1035,9 @@
     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=
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.2.tgz#03f964f19522ba643b1b0693acb9152fe2074baa"
+  integrity sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==
 
 callsites@^3.0.0:
   version "3.1.0"
@@ -920,11 +1059,18 @@
   integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
 
 camelcase@^6.2.0:
-  version "6.2.0"
-  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809"
-  integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==
+  version "6.3.0"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
+  integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
 
-chalk@^2.0.0, chalk@^2.4.1, chalk@^2.4.2:
+chalk-template@^0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/chalk-template/-/chalk-template-0.4.0.tgz#692c034d0ed62436b9062c1707fadcd0f753204b"
+  integrity sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==
+  dependencies:
+    chalk "^4.1.2"
+
+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==
@@ -933,7 +1079,7 @@
     escape-string-regexp "^1.0.5"
     supports-color "^5.3.0"
 
-chalk@^4.0.0, chalk@^4.1.0:
+chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
   integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
@@ -961,11 +1107,6 @@
   optionalDependencies:
     fsevents "~2.3.2"
 
-ci-info@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46"
-  integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==
-
 class-utils@^0.3.5:
   version "0.3.6"
   resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463"
@@ -976,11 +1117,6 @@
     isobject "^3.0.0"
     static-extend "^0.1.1"
 
-cli-boxes@^2.2.1:
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f"
-  integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==
-
 cli-cursor@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307"
@@ -1002,13 +1138,6 @@
     strip-ansi "^6.0.0"
     wrap-ansi "^6.2.0"
 
-clone-response@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b"
-  integrity sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=
-  dependencies:
-    mimic-response "^1.0.0"
-
 clone@^2.1.2:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
@@ -1022,7 +1151,7 @@
 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=
+  integrity sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==
   dependencies:
     map-visit "^1.0.0"
     object-visit "^1.0.0"
@@ -1044,14 +1173,14 @@
 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=
+  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:
+command-line-args@^5.1.1, command-line-args@^5.2.1:
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.1.tgz#c44c32e437a57d7c51157696893c5909e9cec42e"
   integrity sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==
@@ -1061,15 +1190,15 @@
     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==
+command-line-usage@^7.0.0, command-line-usage@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-7.0.1.tgz#e540afef4a4f3bc501b124ffde33956309100655"
+  integrity sha512-NCyznE//MuTjwi3y84QVUGEOT+P5oto1e1Pk/jFPVdPPfsG03qpTIl3yw6etR+v73d0lXsoojRpvbru2sqePxQ==
   dependencies:
-    array-back "^4.0.2"
-    chalk "^2.4.2"
-    table-layout "^1.0.2"
-    typical "^5.2.0"
+    array-back "^6.2.2"
+    chalk-template "^0.4.0"
+    table-layout "^3.0.0"
+    typical "^7.1.1"
 
 commander@^2.20.0:
   version "2.20.3"
@@ -1089,19 +1218,7 @@
 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=
-
-configstore@^5.0.1:
-  version "5.0.1"
-  resolved "https://registry.yarnpkg.com/configstore/-/configstore-5.0.1.tgz#d365021b5df4b98cdd187d6a3b0e3f6a7cc5ed96"
-  integrity sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==
-  dependencies:
-    dot-prop "^5.2.0"
-    graceful-fs "^4.1.2"
-    make-dir "^3.0.0"
-    unique-string "^2.0.0"
-    write-file-atomic "^3.0.0"
-    xdg-basedir "^4.0.0"
+  integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
 
 content-disposition@~0.5.2:
   version "0.5.4"
@@ -1111,9 +1228,9 @@
     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==
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918"
+  integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
 
 cookies@~0.8.0:
   version "0.8.0"
@@ -1126,7 +1243,7 @@
 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-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw==
 
 cross-spawn@^6.0.5:
   version "6.0.5"
@@ -1148,17 +1265,12 @@
     shebang-command "^2.0.0"
     which "^2.0.1"
 
-crypto-random-string@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
-  integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==
-
 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:
+debug@^2.2.0, debug@^2.3.3:
   version "2.6.9"
   resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
   integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
@@ -1172,14 +1284,7 @@
   dependencies:
     ms "^2.1.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:
+debug@^4.1.1, 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==
@@ -1187,9 +1292,9 @@
     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"
-  integrity sha1-0XGoeTMlKAfrPLYdwcFEXQeN8tk=
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.1.tgz#04a2d523b2f18d80d0158a43b895d56dff8d19d8"
+  integrity sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==
   dependencies:
     decamelize "^1.1.0"
     map-obj "^1.0.0"
@@ -1197,76 +1302,62 @@
 decamelize@^1.1.0, decamelize@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
-  integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
+  integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==
 
 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.3.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"
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9"
+  integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==
 
 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==
-
 deep-is@^0.1.3:
-  version "0.1.3"
-  resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
-  integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
+  integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
 
 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==
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
+  integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
 
-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-data-property@^1.0.1:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.0.tgz#0db13540704e1d8d479a0656cf781267531b9451"
+  integrity sha512-UzGwzcjyv3OtAvolTj1GoyNYzfFR+iqbGjcnBEENZVCpM4/Ng1yhGNvS3lR/xDS74Tb2wGG9WzNSNIOS9UVb2g==
+  dependencies:
+    get-intrinsic "^1.2.1"
+    gopd "^1.0.1"
+    has-property-descriptors "^1.0.0"
 
 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"
-  integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==
+define-properties@^1.1.3, define-properties@^1.1.4, define-properties@^1.2.0:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c"
+  integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==
   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:
+    define-data-property "^1.0.1"
     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"
-  integrity sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=
+  integrity sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==
   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=
+  integrity sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==
   dependencies:
     is-descriptor "^1.0.0"
 
@@ -1328,65 +1419,41 @@
   dependencies:
     esutils "^2.0.2"
 
-dom-serializer@^1.0.1:
-  version "1.3.2"
-  resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.3.2.tgz#6206437d32ceefaec7161803230c7a20bc1b4d91"
-  integrity sha512-5c54Bk5Dw4qAxNOI1pFEizPSjVsx5+bpJKmL2kPn8JhBUq2q09tTCa3mjijun2NfK78NMouDYNMBkOrPZiS+ig==
+dom-serializer@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53"
+  integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==
   dependencies:
-    domelementtype "^2.0.1"
-    domhandler "^4.2.0"
-    entities "^2.0.0"
+    domelementtype "^2.3.0"
+    domhandler "^5.0.2"
+    entities "^4.2.0"
 
-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==
+domelementtype@^2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d"
+  integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==
 
-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==
+domhandler@^5.0.2, domhandler@^5.0.3:
+  version "5.0.3"
+  resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31"
+  integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==
   dependencies:
-    domelementtype "^2.2.0"
+    domelementtype "^2.3.0"
 
-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==
+domutils@^3.0.1:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.1.0.tgz#c47f551278d3dc4b0b1ab8cbb42d751a6f0d824e"
+  integrity sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==
   dependencies:
-    domelementtype "^2.2.0"
-
-domutils@^2.8.0:
-  version "2.8.0"
-  resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135"
-  integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==
-  dependencies:
-    dom-serializer "^1.0.1"
-    domelementtype "^2.2.0"
-    domhandler "^4.2.0"
-
-dot-prop@^5.2.0:
-  version "5.3.0"
-  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88"
-  integrity sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==
-  dependencies:
-    is-obj "^2.0.0"
-
-duplexer3@^0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
-  integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=
+    dom-serializer "^2.0.0"
+    domelementtype "^2.3.0"
+    domhandler "^5.0.3"
 
 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"
-  integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==
-
 emoji-regex@^8.0.0:
   version "8.0.0"
   resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
@@ -1397,22 +1464,10 @@
   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"
-  integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==
-  dependencies:
-    once "^1.4.0"
-
-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==
+entities@^4.2.0, entities@^4.4.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
+  integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
 
 error-ex@^1.3.1:
   version "1.3.2"
@@ -1421,65 +1476,64 @@
   dependencies:
     is-arrayish "^0.2.1"
 
-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==
+es-abstract@^1.22.1:
+  version "1.22.2"
+  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.22.2.tgz#90f7282d91d0ad577f505e423e52d4c1d93c1b8a"
+  integrity sha512-YoxfFcDmhjOgWPWsV13+2RNjq1F6UQnfs+8TftwNqtzlmFzEXvlUwdrNrYeaizfjQzRMxkZ6ElWMOJIFKdVqwA==
   dependencies:
+    array-buffer-byte-length "^1.0.0"
+    arraybuffer.prototype.slice "^1.0.2"
+    available-typed-arrays "^1.0.5"
     call-bind "^1.0.2"
+    es-set-tostringtag "^2.0.1"
     es-to-primitive "^1.2.1"
-    function-bind "^1.1.1"
-    function.prototype.name "^1.1.5"
-    get-intrinsic "^1.1.1"
+    function.prototype.name "^1.1.6"
+    get-intrinsic "^1.2.1"
     get-symbol-description "^1.0.0"
+    globalthis "^1.0.3"
+    gopd "^1.0.1"
     has "^1.0.3"
     has-property-descriptors "^1.0.0"
+    has-proto "^1.0.1"
     has-symbols "^1.0.3"
-    internal-slot "^1.0.3"
-    is-callable "^1.2.4"
+    internal-slot "^1.0.5"
+    is-array-buffer "^3.0.2"
+    is-callable "^1.2.7"
     is-negative-zero "^2.0.2"
     is-regex "^1.1.4"
     is-shared-array-buffer "^1.0.2"
     is-string "^1.0.7"
+    is-typed-array "^1.1.12"
     is-weakref "^1.0.2"
-    object-inspect "^1.12.0"
+    object-inspect "^1.12.3"
     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"
+    object.assign "^4.1.4"
+    regexp.prototype.flags "^1.5.1"
+    safe-array-concat "^1.0.1"
+    safe-regex-test "^1.0.0"
+    string.prototype.trim "^1.2.8"
+    string.prototype.trimend "^1.0.7"
+    string.prototype.trimstart "^1.0.7"
+    typed-array-buffer "^1.0.0"
+    typed-array-byte-length "^1.0.0"
+    typed-array-byte-offset "^1.0.0"
+    typed-array-length "^1.0.4"
     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.4"
-    is-negative-zero "^2.0.1"
-    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"
-    string.prototype.trimend "^1.0.4"
-    string.prototype.trimstart "^1.0.4"
-    unbox-primitive "^1.0.1"
+    which-typed-array "^1.1.11"
 
 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==
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.3.1.tgz#c1b0dd5ada807a3b3155315911f364dc4e909db1"
+  integrity sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q==
+
+es-set-tostringtag@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz#338d502f6f674301d710b80c8592de8a15f09cd8"
+  integrity sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==
+  dependencies:
+    get-intrinsic "^1.1.3"
+    has "^1.0.3"
+    has-tostringtag "^1.0.0"
 
 es-shim-unscopables@^1.0.0:
   version "1.0.0"
@@ -1497,137 +1551,33 @@
     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==
+"esbuild@^0.16 || ^0.17":
+  version "0.17.19"
+  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.17.19.tgz#087a727e98299f0462a3d0bcdd9cd7ff100bd955"
+  integrity sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==
   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==
+    "@esbuild/android-arm" "0.17.19"
+    "@esbuild/android-arm64" "0.17.19"
+    "@esbuild/android-x64" "0.17.19"
+    "@esbuild/darwin-arm64" "0.17.19"
+    "@esbuild/darwin-x64" "0.17.19"
+    "@esbuild/freebsd-arm64" "0.17.19"
+    "@esbuild/freebsd-x64" "0.17.19"
+    "@esbuild/linux-arm" "0.17.19"
+    "@esbuild/linux-arm64" "0.17.19"
+    "@esbuild/linux-ia32" "0.17.19"
+    "@esbuild/linux-loong64" "0.17.19"
+    "@esbuild/linux-mips64el" "0.17.19"
+    "@esbuild/linux-ppc64" "0.17.19"
+    "@esbuild/linux-riscv64" "0.17.19"
+    "@esbuild/linux-s390x" "0.17.19"
+    "@esbuild/linux-x64" "0.17.19"
+    "@esbuild/netbsd-x64" "0.17.19"
+    "@esbuild/openbsd-x64" "0.17.19"
+    "@esbuild/sunos-x64" "0.17.19"
+    "@esbuild/win32-arm64" "0.17.19"
+    "@esbuild/win32-ia32" "0.17.19"
+    "@esbuild/win32-x64" "0.17.19"
 
 escape-html@^1.0.3:
   version "1.0.3"
@@ -1637,7 +1587,7 @@
 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"
@@ -1654,21 +1604,21 @@
   resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-7.2.0.tgz#f4a4bd2832e810e8cc7c1411ec85b3e85c0c53f9"
   integrity sha512-rV4Qu0C3nfJKPOAhFujFxB7RMP+URFyQqqOZW9DMRD7ZDTFyjaIlETU3xzHELt++4ugC0+Jm084HQYkkJe+Ivg==
 
-eslint-import-resolver-node@^0.3.6:
-  version "0.3.6"
-  resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz#4048b958395da89668252001dbd9eca6b83bacbd"
-  integrity sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==
+eslint-import-resolver-node@^0.3.7:
+  version "0.3.9"
+  resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz#d4eaac52b8a2e7c3cd1903eb00f7e053356118ac"
+  integrity sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==
   dependencies:
     debug "^3.2.7"
-    resolve "^1.20.0"
+    is-core-module "^2.13.0"
+    resolve "^1.22.4"
 
-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==
+eslint-module-utils@^2.8.0:
+  version "2.8.0"
+  resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz#e439fee65fc33f6bba630ff621efc38ec0375c49"
+  integrity sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==
   dependencies:
     debug "^3.2.7"
-    find-up "^2.1.0"
 
 eslint-plugin-es@^3.0.0:
   version "3.0.1"
@@ -1678,49 +1628,54 @@
     eslint-utils "^2.0.0"
     regexpp "^3.0.0"
 
-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==
+eslint-plugin-html@^7.1.0:
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-html/-/eslint-plugin-html-7.1.0.tgz#aec2a3772b40ccf51a5be4f972f07600539d3b3e"
+  integrity sha512-fNLRraV/e6j8e3XYOC9xgND4j+U7b1Rq+OygMlLcMg+wI/IpVbF+ubQa3R78EjKB9njT6TQOlcK5rFKBVVtdfg==
   dependencies:
-    htmlparser2 "^7.1.2"
+    htmlparser2 "^8.0.1"
 
-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==
+eslint-plugin-import@^2.28.1:
+  version "2.28.1"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.28.1.tgz#63b8b5b3c409bfc75ebaf8fb206b07ab435482c4"
+  integrity sha512-9I9hFlITvOV55alzoKBI+K9q74kv0iKMeY6av5+umsNwayt59fz692daGyjR+oStBQgx6nwR9rXldDev3Clw+A==
   dependencies:
-    array-includes "^3.1.4"
-    array.prototype.flat "^1.2.5"
-    debug "^2.6.9"
+    array-includes "^3.1.6"
+    array.prototype.findlastindex "^1.2.2"
+    array.prototype.flat "^1.3.1"
+    array.prototype.flatmap "^1.3.1"
+    debug "^3.2.7"
     doctrine "^2.1.0"
-    eslint-import-resolver-node "^0.3.6"
-    eslint-module-utils "^2.7.3"
+    eslint-import-resolver-node "^0.3.7"
+    eslint-module-utils "^2.8.0"
     has "^1.0.3"
-    is-core-module "^2.8.1"
+    is-core-module "^2.13.0"
     is-glob "^4.0.3"
     minimatch "^3.1.2"
-    object.values "^1.1.5"
-    resolve "^1.22.0"
-    tsconfig-paths "^3.14.1"
+    object.fromentries "^2.0.6"
+    object.groupby "^1.0.0"
+    object.values "^1.1.6"
+    semver "^6.3.1"
+    tsconfig-paths "^3.14.2"
 
-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==
+eslint-plugin-jsdoc@^44.2.7:
+  version "44.2.7"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-44.2.7.tgz#5ecdb46ddfca209ecd58fff972a4eb74b8dde599"
+  integrity sha512-PcAJO7Wh4xIHPT+StBRpEbWgwCpIrYk75zL31RMbduVVHpgiy3Y8aXQ6pdbRJOq0fxHuepWSEAve8ZrPWTSKRg==
   dependencies:
-    "@es-joy/jsdoccomment" "~0.36.1"
+    "@es-joy/jsdoccomment" "~0.39.4"
+    are-docs-informative "^0.0.2"
     comment-parser "1.3.1"
     debug "^4.3.4"
     escape-string-regexp "^4.0.0"
-    esquery "^1.4.0"
-    semver "^7.3.8"
+    esquery "^1.5.0"
+    semver "^7.5.1"
     spdx-expression-parse "^3.0.1"
 
-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==
+eslint-plugin-lit@^1.9.1:
+  version "1.9.1"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-lit/-/eslint-plugin-lit-1.9.1.tgz#40cdd1f8d1b565eb5e913eab159c88f6f947bb19"
+  integrity sha512-XFFVufVxYJwqRB9sLkDXB7SvV1xi9hrC4HRFEdX1h9+iZ3dm4x9uS7EuT9uaXs6zR3DEgcojia1F7pmvWbc4Gg==
   dependencies:
     parse5 "^6.0.1"
     parse5-htmlparser2-tree-adapter "^6.0.1"
@@ -1745,17 +1700,17 @@
   dependencies:
     prettier-linter-helpers "^1.0.0"
 
-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==
+eslint-plugin-prettier@^4.2.1:
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz#651cbb88b1dab98bfd42f017a12fa6b2d993f94b"
+  integrity sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==
   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-plugin-regex@^1.10.0:
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-regex/-/eslint-plugin-regex-1.10.0.tgz#d182cedbeb89eb03cd8e53f750f6f92e14fa6f9c"
+  integrity sha512-C8/qYKkkbIb0epxKzaz4aw7oVAOmm19fJpR/moUrUToq/vc4xW4sEKMlTQqH6EtNGpvLjYsbbZRlWNWwQGeTSA==
 
 eslint-scope@^5.1.1:
   version "5.1.1"
@@ -1765,10 +1720,10 @@
     esrecurse "^4.3.0"
     estraverse "^4.1.1"
 
-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==
+eslint-scope@^7.2.2:
+  version "7.2.2"
+  resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.2.2.tgz#deb4f92563390f32006894af62a22dba1c46423f"
+  integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==
   dependencies:
     esrecurse "^4.3.0"
     estraverse "^5.2.0"
@@ -1780,82 +1735,72 @@
   dependencies:
     eslint-visitor-keys "^1.1.0"
 
-eslint-utils@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-3.0.0.tgz#8aebaface7345bb33559db0a1f13a1d2d48c3672"
-  integrity sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==
-  dependencies:
-    eslint-visitor-keys "^2.0.0"
-
 eslint-visitor-keys@^1.1.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e"
   integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==
 
-eslint-visitor-keys@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303"
-  integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==
+eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3:
+  version "3.4.3"
+  resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800"
+  integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
 
-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==
+eslint@^7.10.0, eslint@^8.49.0:
+  version "8.49.0"
+  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.49.0.tgz#09d80a89bdb4edee2efcf6964623af1054bf6d42"
+  integrity sha512-jw03ENfm6VJI0jA9U+8H5zfl5b+FvuU3YYvZRdZHOlU2ggJkxrlkJH4HcDrZpj6YwD8kuYqvQM8LyesoazrSOQ==
   dependencies:
-    "@eslint/eslintrc" "^1.3.0"
-    "@humanwhocodes/config-array" "^0.9.2"
-    ajv "^6.10.0"
+    "@eslint-community/eslint-utils" "^4.2.0"
+    "@eslint-community/regexpp" "^4.6.1"
+    "@eslint/eslintrc" "^2.1.2"
+    "@eslint/js" "8.49.0"
+    "@humanwhocodes/config-array" "^0.11.11"
+    "@humanwhocodes/module-importer" "^1.0.1"
+    "@nodelib/fs.walk" "^1.2.8"
+    ajv "^6.12.4"
     chalk "^4.0.0"
     cross-spawn "^7.0.2"
     debug "^4.3.2"
     doctrine "^3.0.0"
     escape-string-regexp "^4.0.0"
-    eslint-scope "^7.1.1"
-    eslint-utils "^3.0.0"
-    eslint-visitor-keys "^3.3.0"
-    espree "^9.3.2"
-    esquery "^1.4.0"
+    eslint-scope "^7.2.2"
+    eslint-visitor-keys "^3.4.3"
+    espree "^9.6.1"
+    esquery "^1.4.2"
     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 "^6.0.1"
-    globals "^13.15.0"
+    find-up "^5.0.0"
+    glob-parent "^6.0.2"
+    globals "^13.19.0"
+    graphemer "^1.4.0"
     ignore "^5.2.0"
-    import-fresh "^3.0.0"
     imurmurhash "^0.1.4"
     is-glob "^4.0.0"
+    is-path-inside "^3.0.3"
     js-yaml "^4.1.0"
     json-stable-stringify-without-jsonify "^1.0.1"
     levn "^0.4.1"
     lodash.merge "^4.6.2"
     minimatch "^3.1.2"
     natural-compare "^1.4.0"
-    optionator "^0.9.1"
-    regexpp "^3.2.0"
+    optionator "^0.9.3"
     strip-ansi "^6.0.1"
-    strip-json-comments "^3.1.0"
     text-table "^0.2.0"
-    v8-compile-cache "^2.0.3"
 
-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==
+espree@^9.6.0, espree@^9.6.1:
+  version "9.6.1"
+  resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f"
+  integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==
   dependencies:
-    acorn "^8.7.1"
+    acorn "^8.9.0"
     acorn-jsx "^5.3.2"
-    eslint-visitor-keys "^3.3.0"
+    eslint-visitor-keys "^3.4.1"
 
-esquery@^1.4.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.4.0.tgz#2148ffc38b82e8c7057dfed48425b3e61f0f24a5"
-  integrity sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==
+esquery@^1.4.2, esquery@^1.5.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b"
+  integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==
   dependencies:
     estraverse "^5.1.0"
 
@@ -1872,9 +1817,9 @@
   integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
 
 estraverse@^5.1.0, estraverse@^5.2.0:
-  version "5.2.0"
-  resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.2.0.tgz#307df42547e6cc7324d3cf03c155d5cdb8c53880"
-  integrity sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==
+  version "5.3.0"
+  resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
+  integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==
 
 estree-walker@^1.0.1:
   version "1.0.1"
@@ -1909,7 +1854,7 @@
 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=
+  integrity sha512-w/ozOKR9Obk3qoWeY/WDi6MFta9AoMR+zud60mdnbniMcBxRuFJyDt2LdX/14A1UABeqk+Uk+LDfUpvoGKppZA==
   dependencies:
     debug "^2.3.3"
     define-property "^0.2.5"
@@ -1922,14 +1867,14 @@
 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=
+  integrity sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==
   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=
+  integrity sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==
   dependencies:
     assign-symbols "^1.0.0"
     is-extendable "^1.0.1"
@@ -1963,9 +1908,9 @@
   integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
 
 fast-diff@^1.1.2:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03"
-  integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0"
+  integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==
 
 fast-glob@^2.2.6:
   version "2.2.7"
@@ -1979,21 +1924,10 @@
     merge2 "^1.2.3"
     micromatch "^3.1.10"
 
-fast-glob@^3.2.2:
-  version "3.2.7"
-  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.7.tgz#fd6cb7a2d7e9aa7a7846111e85a196d6b2f766a1"
-  integrity sha512-rYGMRwip6lUMvYD3BTScMwT1HtAs2d71SMv66Vrxs0IekGZEjhM0pcMfjQPnknBt2zeCwQMEupiN02ZP4DiT1Q==
-  dependencies:
-    "@nodelib/fs.stat" "^2.0.2"
-    "@nodelib/fs.walk" "^1.2.3"
-    glob-parent "^5.1.2"
-    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==
+fast-glob@^3.2.2, fast-glob@^3.2.9:
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.1.tgz#784b4e897340f3dbbef17413b3f11acf03c874c4"
+  integrity sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==
   dependencies:
     "@nodelib/fs.stat" "^2.0.2"
     "@nodelib/fs.walk" "^1.2.3"
@@ -2009,12 +1943,12 @@
 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=
+  integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
 
 fastq@^1.6.0:
-  version "1.12.0"
-  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.12.0.tgz#ed7b6ab5d62393fb2cc591c853652a5c318bf794"
-  integrity sha512-VNX0QkHK3RsXVKr9KrlUv/FoTa0NdbYoHHl7uXHv2rzyHSlxjdNAKug2twd9luJxpcyNeAgf5iPPMutJO67Dfg==
+  version "1.15.0"
+  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.15.0.tgz#d04d07c6a2a68fe4599fea8d2e103a937fae6b3a"
+  integrity sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==
   dependencies:
     reusify "^1.0.4"
 
@@ -2035,7 +1969,7 @@
 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=
+  integrity sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==
   dependencies:
     extend-shallow "^2.0.1"
     is-number "^3.0.0"
@@ -2056,13 +1990,6 @@
   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=
-  dependencies:
-    locate-path "^2.0.0"
-
 find-up@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
@@ -2071,28 +1998,44 @@
     locate-path "^5.0.0"
     path-exists "^4.0.0"
 
-flat-cache@^3.0.4:
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11"
-  integrity sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==
+find-up@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc"
+  integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==
   dependencies:
-    flatted "^3.1.0"
+    locate-path "^6.0.0"
+    path-exists "^4.0.0"
+
+flat-cache@^3.0.4:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.1.0.tgz#0e54ab4a1a60fe87e2946b6b00657f1c99e1af3f"
+  integrity sha512-OHx4Qwrrt0E4jEIcI5/Xb+f+QmJYNj2rrK8wiIdQOIrB9WrrJL8cjZvXdXuBTkkEwEqLycb5BeZDV1o2i9bTew==
+  dependencies:
+    flatted "^3.2.7"
+    keyv "^4.5.3"
     rimraf "^3.0.2"
 
-flatted@^3.1.0:
-  version "3.2.2"
-  resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.2.tgz#64bfed5cb68fe3ca78b3eb214ad97b63bedce561"
-  integrity sha512-JaTY/wtrcSyvXJl4IMFHPKyFur1sE9AUqc0QnhOaJ0CxHtAoIV8pYDzeEfAaNEtGkOfq4gr3LBFmdXW5mOQFnA==
+flatted@^3.2.7:
+  version "3.2.9"
+  resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.9.tgz#7eb4c67ca1ba34232ca9d2d93e9886e611ad7daf"
+  integrity sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==
+
+for-each@^0.3.3:
+  version "0.3.3"
+  resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e"
+  integrity sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==
+  dependencies:
+    is-callable "^1.1.3"
 
 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=
+  integrity sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==
 
 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=
+  integrity sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==
   dependencies:
     map-cache "^0.2.2"
 
@@ -2104,34 +2047,29 @@
 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=
+  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==
+  version "2.3.3"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
+  integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
 
 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==
 
-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==
+function.prototype.name@^1.1.6:
+  version "1.1.6"
+  resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.6.tgz#cdf315b7d90ee77a4c6ee216c3c3362da07533fd"
+  integrity sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==
   dependencies:
     call-bind "^1.0.2"
-    define-properties "^1.1.3"
-    es-abstract "^1.19.0"
-    functions-have-names "^1.2.2"
+    define-properties "^1.2.0"
+    es-abstract "^1.22.1"
+    functions-have-names "^1.2.3"
 
-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:
+functions-have-names@^1.2.3:
   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==
@@ -2141,28 +2079,15 @@
   resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
   integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
 
-get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6"
-  integrity sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==
+get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.0, get-intrinsic@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.1.tgz#d295644fed4505fc9cde952c37ee12b477a83d82"
+  integrity sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==
   dependencies:
     function-bind "^1.1.1"
     has "^1.0.3"
-    has-symbols "^1.0.1"
-
-get-stream@^4.1.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.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"
+    has-proto "^1.0.1"
+    has-symbols "^1.0.3"
 
 get-stream@^6.0.0:
   version "6.0.1"
@@ -2180,12 +2105,12 @@
 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=
+  integrity sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==
 
 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=
+  integrity sha512-E8Ak/2+dZY6fnzlR7+ueWvhsH1SjHr4jjss4YS/h4py44jY9MhK/VFdaZJAWDz6BbL21KeteKxFSFpq8OS5gVA==
   dependencies:
     is-glob "^3.1.0"
     path-dirname "^1.0.0"
@@ -2197,7 +2122,7 @@
   dependencies:
     is-glob "^4.0.1"
 
-glob-parent@^6.0.1:
+glob-parent@^6.0.2:
   version "6.0.2"
   resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
   integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
@@ -2207,34 +2132,34 @@
 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=
+  integrity sha512-Iozmtbqv0noj0uDDqoL0zNq0VBEfK2YFoMAZoxJe4cwphvLR+JskfF30QhXHOR4m3KrE6NLRYw+U9MRXvifyig==
 
 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==
+  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@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-3.0.0.tgz#70a76fe84ea315ab37b1f5576cbde7d48ef72686"
-  integrity sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA==
-  dependencies:
-    ini "2.0.0"
-
-globals@^13.15.0:
-  version "13.15.0"
-  resolved "https://registry.yarnpkg.com/globals/-/globals-13.15.0.tgz#38113218c907d2f7e98658af246cef8b77e90bac"
-  integrity sha512-bpzcOlgDhMG070Av0Vy5Owklpv1I6+j96GhUI7Rh7IzDCKLzboflLrrfqMu8NquDbiR4EOQk7XzJwqVJxicxog==
+globals@^13.19.0:
+  version "13.22.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-13.22.0.tgz#0c9fcb9c48a2494fbb5edbfee644285543eba9d8"
+  integrity sha512-H1Ddc/PbZHTDVJSnj8kWptIRSD6AM3pK+mKytuIVF4uoBV7rshFlhhvA58ceJ5wp3Er58w6zj7bykMpYXt3ETw==
   dependencies:
     type-fest "^0.20.2"
 
+globalthis@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.3.tgz#5852882a52b80dc301b0660273e1ed082f0b6ccf"
+  integrity sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==
+  dependencies:
+    define-properties "^1.1.3"
+
 globby@^11.1.0:
   version "11.1.0"
   resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b"
@@ -2248,36 +2173,31 @@
     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==
+  version "3.21.2"
+  resolved "https://registry.yarnpkg.com/google-protobuf/-/google-protobuf-3.21.2.tgz#4580a2bea8bbb291ee579d1fefb14d6fa3070ea4"
+  integrity sha512-3MSOYFO5U9mPGikIYCzK0SaThypfGgS6bHqrUGXG3DPHCrb+txNqeEcns1W0lkGfk0rCyNXm7xB9rMxnCiZOoA==
 
-got@^9.6.0:
-  version "9.6.0"
-  resolved "https://registry.yarnpkg.com/got/-/got-9.6.0.tgz#edf45e7d67f99545705de1f7bbeeeb121765ed85"
-  integrity sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==
+gopd@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c"
+  integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==
   dependencies:
-    "@sindresorhus/is" "^0.14.0"
-    "@szmarczak/http-timer" "^1.1.2"
-    cacheable-request "^6.0.0"
-    decompress-response "^3.3.0"
-    duplexer3 "^0.1.4"
-    get-stream "^4.1.0"
-    lowercase-keys "^1.0.1"
-    mimic-response "^1.0.1"
-    p-cancelable "^1.0.0"
-    to-readable-stream "^1.0.0"
-    url-parse-lax "^3.0.0"
+    get-intrinsic "^1.1.3"
 
 graceful-fs@^4.1.2:
-  version "4.2.8"
-  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a"
-  integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==
+  version "4.2.11"
+  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
+  integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
 
-gts@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/gts/-/gts-3.1.0.tgz#b27ce914191ed6ad34781968d0c77e0ed3042388"
-  integrity sha512-Pbj3ob1VR1IRlEVEBNtKoQ1wHOa8cZz62KEojK8Fn/qeS2ClWI4gLNfhek3lD68aZSmUEg8TFb6AHXIwUMgyqQ==
+graphemer@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6"
+  integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==
+
+gts@^3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/gts/-/gts-3.1.1.tgz#c7347cf8f8ea32577909659b22bf698ac5ca8082"
+  integrity sha512-Jw44aBbzMnd1vtZs7tZt3LMstKQukCBg7N4CKVGzviIQ45Cz5b9lxDJGXVKj/9ySuGv6TYEeijZJGbiiVcM27w==
   dependencies:
     "@typescript-eslint/eslint-plugin" "^4.2.0"
     "@typescript-eslint/parser" "^4.2.0"
@@ -2293,7 +2213,6 @@
     ncp "^2.0.0"
     prettier "^2.1.2"
     rimraf "^3.0.2"
-    update-notifier "^5.0.0"
     write-file-atomic "^3.0.3"
 
 hard-rejection@^2.1.0:
@@ -2301,12 +2220,7 @@
   resolved "https://registry.yarnpkg.com/hard-rejection/-/hard-rejection-2.1.0.tgz#1c6eda5c1685c63942766d79bb40ae773cecd883"
   integrity sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==
 
-has-bigints@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113"
-  integrity sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==
-
-has-bigints@^1.0.2:
+has-bigints@^1.0.1, 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==
@@ -2314,7 +2228,7 @@
 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=
+  integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==
 
 has-flag@^4.0.0:
   version "4.0.0"
@@ -2328,12 +2242,12 @@
   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-proto@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.1.tgz#1885c1305538958aff469fef37937c22795408e0"
+  integrity sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==
 
-has-symbols@^1.0.3:
+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==
@@ -2348,7 +2262,7 @@
 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=
+  integrity sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q==
   dependencies:
     get-value "^2.0.3"
     has-values "^0.1.4"
@@ -2357,7 +2271,7 @@
 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=
+  integrity sha512-IBXk4GTsLYdQ7Rvt+GRBrFSVEkmuOUy4re0Xjd9kJSUQpnTrWR4/y9RpfexN9vkAPMFuQoeWKwqzPozRTlasGw==
   dependencies:
     get-value "^2.0.6"
     has-values "^1.0.0"
@@ -2366,21 +2280,16 @@
 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=
+  integrity sha512-J8S0cEdWuQbqD9//tlZxiMuMNmxB8PlEwvYwuxsTmR1G5RXUePEX/SJn7aD0GMLieuZYSwNH0cQuJGwnYunXRQ==
 
 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=
+  integrity sha512-ODYZC64uqzmtfGMEAX/FvZiRyWLpAC3vYnNunURUnkGVTS+mI0smVsWaPydRBsE3g+ok7h960jChO8mFcWlHaQ==
   dependencies:
     is-number "^3.0.0"
     kind-of "^4.0.0"
 
-has-yarn@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-2.1.0.tgz#137e11354a7b5bf11aa5cb649cf0c6f3ff2b2e77"
-  integrity sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==
-
 has@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
@@ -2394,21 +2303,21 @@
   integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==
 
 hosted-git-info@^4.0.1:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-4.0.2.tgz#5e425507eede4fea846b7262f0838456c4209961"
-  integrity sha512-c9OGXbZ3guC/xOlCg1Ci/VgWlwsqDv1yMQL1CWqXDL0hDjXuNcq0zuR4xqPSuasI3kqFDhqSyTjREz5gzq0fXg==
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-4.1.0.tgz#827b82867e9ff1c8d0c4d9d53880397d2c86d224"
+  integrity sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==
   dependencies:
     lru-cache "^6.0.0"
 
-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==
+htmlparser2@^8.0.1:
+  version "8.0.2"
+  resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21"
+  integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==
   dependencies:
-    domelementtype "^2.0.1"
-    domhandler "^4.2.2"
-    domutils "^2.8.0"
-    entities "^3.0.1"
+    domelementtype "^2.3.0"
+    domhandler "^5.0.3"
+    domutils "^3.0.1"
+    entities "^4.4.0"
 
 http-assert@^1.3.0:
   version "1.5.0"
@@ -2418,11 +2327,6 @@
     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"
@@ -2456,17 +2360,12 @@
   dependencies:
     safer-buffer ">= 2.1.2 < 3"
 
-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.1.1, ignore@^5.2.0:
+  version "5.2.4"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324"
+  integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==
 
-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:
+import-fresh@^3.2.1:
   version "3.3.0"
   resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
   integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==
@@ -2474,15 +2373,10 @@
     parent-module "^1.0.0"
     resolve-from "^4.0.0"
 
-import-lazy@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43"
-  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=
+  integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==
 
 indent-string@^4.0.0:
   version "4.0.0"
@@ -2492,7 +2386,7 @@
 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"
@@ -2507,16 +2401,6 @@
   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"
-  integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==
-
-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@^7.3.3:
   version "7.3.3"
   resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.3.3.tgz#04d176b2af04afc157a83fd7c100e98ee0aad003"
@@ -2536,12 +2420,12 @@
     strip-ansi "^6.0.0"
     through "^2.3.6"
 
-internal-slot@^1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.3.tgz#7347e307deeea2faac2ac6205d4bc7d34967f59c"
-  integrity sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==
+internal-slot@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.5.tgz#f2a2ee21f668f8627a4667f309dc0f4fb6674986"
+  integrity sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==
   dependencies:
-    get-intrinsic "^1.1.0"
+    get-intrinsic "^1.2.0"
     has "^1.0.3"
     side-channel "^1.0.4"
 
@@ -2553,7 +2437,7 @@
 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=
+  integrity sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==
   dependencies:
     kind-of "^3.0.2"
 
@@ -2564,10 +2448,19 @@
   dependencies:
     kind-of "^6.0.0"
 
+is-array-buffer@^3.0.1, is-array-buffer@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.2.tgz#f2653ced8412081638ecb0ebbd0c41c6e0aecbbe"
+  integrity sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==
+  dependencies:
+    call-bind "^1.0.2"
+    get-intrinsic "^1.2.0"
+    is-typed-array "^1.1.10"
+
 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-bigint@^1.0.1:
   version "1.0.4"
@@ -2597,49 +2490,28 @@
   integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==
 
 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==
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.1.tgz#f03271717d8654cfcaf07ab0463faa3571581169"
+  integrity sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==
   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==
+is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7:
+  version "1.2.7"
+  resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055"
+  integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==
 
-is-ci@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c"
-  integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==
-  dependencies:
-    ci-info "^2.0.0"
-
-is-core-module@^2.2.0, 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==
+is-core-module@^2.13.0, is-core-module@^2.5.0:
+  version "2.13.0"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.0.tgz#bb52aa6e2cbd49a30c2ba68c42bf3435ba6072db"
+  integrity sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==
   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=
+  integrity sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==
   dependencies:
     kind-of "^3.0.2"
 
@@ -2683,7 +2555,7 @@
 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=
+  integrity sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==
 
 is-extendable@^1.0.1:
   version "1.0.1"
@@ -2695,12 +2567,7 @@
 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-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=
+  integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
 
 is-fullwidth-code-point@^3.0.0:
   version "3.0.0"
@@ -2717,63 +2584,38 @@
 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=
+  integrity sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw==
   dependencies:
     is-extglob "^2.1.0"
 
-is-glob@^4.0.0, is-glob@^4.0.1:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc"
-  integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==
-  dependencies:
-    is-extglob "^2.1.1"
-
-is-glob@^4.0.3, is-glob@~4.0.1:
+is-glob@^4.0.0, is-glob@^4.0.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"
-  integrity sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==
-  dependencies:
-    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"
-  integrity sha512-WW/rQLOazUq+ST/bCAVBp/2oMERWLsR7OrKyt052dNDk4DHcDE0/7QSXITlmi+VBcV13DfIbysG3tZJm5RfdBA==
-
 is-number-object@^1.0.4:
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.6.tgz#6a7aaf838c7f0686a50b4553f7e54a96494e89f0"
-  integrity sha512-bEVOqiRcvo3zO1+G2lVMy+gkkEm9Yh7cDMRusKKu5ZJKPUYSJwICTKZrNKHA2EbSP0Tu0+6B/emsYNHZyn6K8g==
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.7.tgz#59d50ada4c45251784e9904f5246c742f07a42fc"
+  integrity sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==
   dependencies:
     has-tostringtag "^1.0.0"
 
 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=
+  integrity sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==
   dependencies:
     kind-of "^3.0.2"
 
@@ -2782,12 +2624,7 @@
   resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
   integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
 
-is-obj@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982"
-  integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==
-
-is-path-inside@^3.0.2:
+is-path-inside@^3.0.3:
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283"
   integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==
@@ -2795,7 +2632,7 @@
 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=
+  integrity sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==
 
 is-plain-object@^2.0.3, is-plain-object@^2.0.4:
   version "2.0.4"
@@ -2812,11 +2649,6 @@
     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"
@@ -2843,17 +2675,17 @@
   dependencies:
     has-symbols "^1.0.2"
 
+is-typed-array@^1.1.10, is-typed-array@^1.1.12, is-typed-array@^1.1.9:
+  version "1.1.12"
+  resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.12.tgz#d0bab5686ef4a76f7a73097b95470ab199c57d4a"
+  integrity sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==
+  dependencies:
+    which-typed-array "^1.1.11"
+
 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-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"
+  integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==
 
 is-weakref@^1.0.2:
   version "1.0.2"
@@ -2874,37 +2706,37 @@
   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"
-  integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==
-
 isarray@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
-  integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
+  integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==
 
-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==
+isarray@^2.0.5:
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723"
+  integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==
+
+isbinaryfile@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-5.0.0.tgz#034b7e54989dab8986598cbcea41f66663c65234"
+  integrity sha512-UDdnyGvMajJUWCkib7Cei/dvyJrrvo4FIrsvSFWdPpXSUorzXrDJ0S+X5Q4ZlasfPjca4yqCNNsjbCeiy8FFeg==
 
 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==
 
 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=
+  integrity sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA==
   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=
+  integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==
 
 js-tokens@^4.0.0:
   version "4.0.0"
@@ -2918,15 +2750,15 @@
   dependencies:
     argparse "^2.0.1"
 
-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==
+jsdoc-type-pratt-parser@~4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.0.0.tgz#136f0571a99c184d84ec84662c45c29ceff71114"
+  integrity sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ==
 
-json-buffer@3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898"
-  integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=
+json-buffer@3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13"
+  integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==
 
 json-parse-better-errors@^1.0.1:
   version "1.0.2"
@@ -2946,21 +2778,19 @@
 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=
+  integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==
 
-json5@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe"
-  integrity sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==
+json5@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593"
+  integrity sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==
   dependencies:
     minimist "^1.2.0"
 
 json5@^2.1.3:
-  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"
+  version "2.2.3"
+  resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
+  integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
 
 keygrip@~1.1.0:
   version "1.1.0"
@@ -2969,24 +2799,24 @@
   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"
-  integrity sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==
+keyv@^4.5.3:
+  version "4.5.3"
+  resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.3.tgz#00873d2b046df737963157bd04f294ca818c9c25"
+  integrity sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==
   dependencies:
-    json-buffer "3.0.0"
+    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=
+  integrity sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==
   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=
+  integrity sha512-24XsCxmEbRwEDbz/qz3stgin8TTzZ1ESR56OMCN0ujYg+vRutNSiOj9bHH9u85DKgXguraugV5sFuvbD4FW/hw==
   dependencies:
     is-buffer "^1.1.5"
 
@@ -3038,9 +2868,9 @@
     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==
+  version "2.14.2"
+  resolved "https://registry.yarnpkg.com/koa/-/koa-2.14.2.tgz#a57f925c03931c2b4d94b19d2ebf76d3244863fc"
+  integrity sha512-VFI2bpJaodz6P7x2uyLiX6RLYpZmOJqNmoCst/Yyd7hQlszyPwG/I9CQJ63nOtKSxpt5M7NH67V6nJL2BwCl7g==
   dependencies:
     accepts "^1.3.5"
     cache-content-type "^1.0.0"
@@ -3066,13 +2896,6 @@
     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"
-  integrity sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==
-  dependencies:
-    package-json "^6.3.0"
-
 leven@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2"
@@ -3087,9 +2910,9 @@
     type-check "~0.4.0"
 
 lines-and-columns@^1.1.6:
-  version "1.1.6"
-  resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
-  integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
+  integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
 
 lit-analyzer@1.2.1, lit-analyzer@^1.2.1:
   version "1.2.1"
@@ -3108,21 +2931,13 @@
 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"
     pify "^3.0.0"
     strip-bom "^3.0.0"
 
-locate-path@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"
-  integrity sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=
-  dependencies:
-    p-locate "^2.0.0"
-    path-exists "^3.0.0"
-
 locate-path@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0"
@@ -3130,6 +2945,18 @@
   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"
+  integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==
+  dependencies:
+    p-locate "^5.0.0"
+
+lodash.assignwith@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/lodash.assignwith/-/lodash.assignwith-4.2.0.tgz#127a97f02adc41751a954d24b0de17e100e038eb"
+  integrity sha512-ZznplvbvtjK2gMvnQ1BR/zqPFZmS6jbK4p+6Up4xcRYA7yMIwxHCfbTcrYxXKzzqLsQ05eJPVznEW3tuwV7k1g==
+
 lodash.camelcase@^4.3.0:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
@@ -3138,7 +2965,7 @@
 lodash.deburr@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/lodash.deburr/-/lodash.deburr-4.1.0.tgz#ddb1bbb3ef07458c0177ba07de14422cb033ff9b"
-  integrity sha1-3bG7s+8HRYwBd7oH3hRCLLAz/5s=
+  integrity sha512-m/M1U1f3ddMCs6Hq2tAsYThTBDaAKFDX3dwDo97GEYzamXi9SqUpjWi/Rrj/gf3X2n8ktwgZrlP1z6E3v/IExQ==
 
 lodash.merge@^4.6.2:
   version "4.6.2"
@@ -3155,16 +2982,6 @@
   resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28"
   integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==
 
-lowercase-keys@^1.0.0, lowercase-keys@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f"
-  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@^6.0.0:
   version "6.0.0"
   resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
@@ -3172,32 +2989,25 @@
   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"
-
 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=
+  integrity sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==
 
 map-obj@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d"
-  integrity sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=
+  integrity sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==
 
 map-obj@^4.0.0:
-  version "4.2.1"
-  resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.2.1.tgz#e4ea399dbc979ae735c83c863dd31bdf364277b7"
-  integrity sha512-+WA2/1sPmDj1dlvvJmB5G6JKfY9dpn7EVBUL06+y6PoljPkh+6V1QihwxNkbcGxCRjt2b0F9K0taiCuo7MbdFQ==
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.3.0.tgz#9304f906e93faae70880da102a9f1df0ea8bb05a"
+  integrity sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==
 
 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=
+  integrity sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==
   dependencies:
     object-visit "^1.0.0"
 
@@ -3209,7 +3019,7 @@
 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=
+  integrity sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==
 
 meow@^9.0.0:
   version "9.0.0"
@@ -3259,12 +3069,12 @@
     to-regex "^3.0.2"
 
 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==
+  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.1"
-    picomatch "^2.2.3"
+    braces "^3.0.2"
+    picomatch "^2.3.1"
 
 mime-db@1.52.0:
   version "1.52.0"
@@ -3283,24 +3093,12 @@
   resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
   integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
 
-mimic-response@^1.0.0, mimic-response@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b"
-  integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==
-
 min-indent@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869"
   integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==
 
-minimatch@^3.0.4:
-  version "3.0.4"
-  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:
+minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, 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==
@@ -3316,15 +3114,10 @@
     is-plain-obj "^1.1.0"
     kind-of "^6.0.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==
-
-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.2.0, minimist@^1.2.6:
+  version "1.2.8"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
+  integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
 
 mixin-deep@^1.2.0:
   version "1.3.2"
@@ -3344,7 +3137,7 @@
 ms@2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
-  integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=
+  integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==
 
 ms@2.1.2:
   version "2.1.2"
@@ -3383,15 +3176,20 @@
     snapdragon "^0.8.1"
     to-regex "^3.0.1"
 
+natural-compare-lite@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4"
+  integrity sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==
+
 natural-compare@^1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
-  integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
+  integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
 
 ncp@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3"
-  integrity sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=
+  integrity sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==
 
 negotiator@0.6.3:
   version "0.6.3"
@@ -3428,11 +3226,6 @@
   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"
@@ -3458,23 +3251,18 @@
 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=
+  integrity sha512-79LYn6VAb63zgtmAteVOWo9Vdj71ZVBy3Pbse+VqxDpEP83XuujMrGqHIwAXJ5I/aM0zU7dIyIAhifVTPrNItQ==
   dependencies:
     copy-descriptor "^0.1.0"
     define-property "^0.2.5"
     kind-of "^3.0.3"
 
-object-inspect@^1.11.0, object-inspect@^1.9.0:
-  version "1.11.0"
-  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.11.0.tgz#9dceb146cedd4148a0d9e51ab88d34cf509922b1"
-  integrity sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg==
+object-inspect@^1.12.3, object-inspect@^1.9.0:
+  version "1.12.3"
+  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9"
+  integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==
 
-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:
+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==
@@ -3482,35 +3270,54 @@
 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=
+  integrity sha512-GBaMwwAVK9qbQN3Scdo0OyvgPW7l3lnaVMj84uTOZlswkX0KpF6fyDBJhtTthf7pymztoN36/KEr1DyhF96zEA==
   dependencies:
     isobject "^3.0.0"
 
-object.assign@^4.1.2:
-  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==
+object.assign@^4.1.4:
+  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"
 
+object.fromentries@^2.0.6:
+  version "2.0.7"
+  resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.7.tgz#71e95f441e9a0ea6baf682ecaaf37fa2a8d7e616"
+  integrity sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==
+  dependencies:
+    call-bind "^1.0.2"
+    define-properties "^1.2.0"
+    es-abstract "^1.22.1"
+
+object.groupby@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/object.groupby/-/object.groupby-1.0.1.tgz#d41d9f3c8d6c778d9cbac86b4ee9f5af103152ee"
+  integrity sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==
+  dependencies:
+    call-bind "^1.0.2"
+    define-properties "^1.2.0"
+    es-abstract "^1.22.1"
+    get-intrinsic "^1.2.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=
+  integrity sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==
   dependencies:
     isobject "^3.0.1"
 
-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==
+object.values@^1.1.6:
+  version "1.1.7"
+  resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.7.tgz#617ed13272e7e1071b43973aa1655d9291b8442a"
+  integrity sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==
   dependencies:
     call-bind "^1.0.2"
-    define-properties "^1.1.3"
-    es-abstract "^1.19.1"
+    define-properties "^1.2.0"
+    es-abstract "^1.22.1"
 
 on-finished@^2.3.0:
   version "2.4.1"
@@ -3519,10 +3326,10 @@
   dependencies:
     ee-first "1.1.1"
 
-once@^1.3.0, once@^1.3.1, once@^1.4.0:
+once@^1.3.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"
 
@@ -3539,42 +3346,30 @@
   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==
+  version "8.4.2"
+  resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9"
+  integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==
   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"
-  integrity sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==
+optionator@^0.9.3:
+  version "0.9.3"
+  resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.3.tgz#007397d44ed1872fdc6ed31360190f81814e2c64"
+  integrity sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==
   dependencies:
+    "@aashutoshrathi/word-wrap" "^1.2.3"
     deep-is "^0.1.3"
     fast-levenshtein "^2.0.6"
     levn "^0.4.1"
     prelude-ls "^1.2.1"
     type-check "^0.4.0"
-    word-wrap "^1.2.3"
 
 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=
-
-p-cancelable@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc"
-  integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==
-
-p-limit@^1.1.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8"
-  integrity sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==
-  dependencies:
-    p-try "^1.0.0"
+  integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==
 
 p-limit@^2.2.0:
   version "2.3.0"
@@ -3583,12 +3378,12 @@
   dependencies:
     p-try "^2.0.0"
 
-p-locate@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43"
-  integrity sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=
+p-limit@^3.0.2:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
+  integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
   dependencies:
-    p-limit "^1.1.0"
+    yocto-queue "^0.1.0"
 
 p-locate@^4.1.0:
   version "4.1.0"
@@ -3597,26 +3392,18 @@
   dependencies:
     p-limit "^2.2.0"
 
-p-try@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3"
-  integrity sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=
+p-locate@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834"
+  integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==
+  dependencies:
+    p-limit "^3.0.2"
 
 p-try@^2.0.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
   integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
 
-package-json@^6.3.0:
-  version "6.5.0"
-  resolved "https://registry.yarnpkg.com/package-json/-/package-json-6.5.0.tgz#6feedaca35e75725876d0b0e64974697fed145b0"
-  integrity sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==
-  dependencies:
-    got "^9.6.0"
-    registry-auth-token "^4.0.0"
-    registry-url "^5.0.0"
-    semver "^6.2.0"
-
 parent-module@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
@@ -3627,7 +3414,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"
@@ -3667,17 +3454,12 @@
 pascalcase@^0.1.1:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14"
-  integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=
+  integrity sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==
 
 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@^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-ALzNPpyNq9AqXMBjeymIjFDAkAFH06mHJH/cSBHAgU0s4vfpBn6b2nf8tiRLvagKD8RbTpq2FKTBg7cl9l3c7Q==
 
 path-exists@^4.0.0:
   version "4.0.0"
@@ -3687,19 +3469,19 @@
 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=
+  integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==
 
 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=
+  integrity sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==
 
 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.7:
+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==
@@ -3716,16 +3498,11 @@
   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:
+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==
 
-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"
@@ -3734,12 +3511,12 @@
 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==
 
-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==
+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"
@@ -3748,18 +3525,13 @@
 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=
+  integrity sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg==
 
 prelude-ls@^1.2.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
   integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
 
-prepend-http@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897"
-  integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=
-
 prettier-linter-helpers@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz#d23d41fe1375646de2d0104d3454a3008802cf7b"
@@ -3767,10 +3539,10 @@
   dependencies:
     fast-diff "^1.1.2"
 
-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==
+prettier@^2.1.2, prettier@^2.8.8:
+  version "2.8.8"
+  resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da"
+  integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==
 
 protobufjs@6.8.8:
   version "6.8.8"
@@ -3791,25 +3563,10 @@
     "@types/node" "^10.1.0"
     long "^4.0.0"
 
-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.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==
-
-pupa@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.1.1.tgz#f5e8fd4afc2c5d97828faa523549ed8744a20d62"
-  integrity sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A==
-  dependencies:
-    escape-goat "^2.0.0"
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f"
+  integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==
 
 queue-microtask@^1.2.2:
   version "1.2.3"
@@ -3821,16 +3578,6 @@
   resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f"
   integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==
 
-rc@^1.2.8:
-  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-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"
@@ -3843,7 +3590,7 @@
 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"
@@ -3874,15 +3621,10 @@
     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"
-  integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==
+regenerator-runtime@^0.14.0:
+  version "0.14.0"
+  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz#5e19d68eb12d486f797e15a3c6a918f7cec5eb45"
+  integrity sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==
 
 regex-not@^1.0.0, regex-not@^1.0.2:
   version "1.0.2"
@@ -3892,34 +3634,20 @@
     extend-shallow "^3.0.2"
     safe-regex "^1.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==
+regexp.prototype.flags@^1.5.1:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz#90ce989138db209f81492edd734183ce99f9677e"
+  integrity sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==
   dependencies:
     call-bind "^1.0.2"
-    define-properties "^1.1.3"
-    functions-have-names "^1.2.2"
+    define-properties "^1.2.0"
+    set-function-name "^2.0.0"
 
-regexpp@^3.0.0, regexpp@^3.2.0:
+regexpp@^3.0.0:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2"
   integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==
 
-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"
-  integrity sha512-6gkSb4U6aWJB4SF2ZvLb76yCBjcvufXBqvvEx1HbmKPkutswjW1xNVRY0+daljIYRbogN7O0etYSlbiaEQyMyw==
-  dependencies:
-    rc "^1.2.8"
-
-registry-url@^5.0.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-5.1.0.tgz#e98334b50d5434b81136b44ec638d9c2009c5009"
-  integrity sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw==
-  dependencies:
-    rc "^1.2.8"
-
 repeat-element@^1.1.2:
   version "1.1.4"
   resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.4.tgz#be681520847ab58c7568ac75fbfad28ed42d39e9"
@@ -3928,12 +3656,12 @@
 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=
+  integrity sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==
 
 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"
@@ -3961,41 +3689,17 @@
 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=
+  integrity sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==
 
-resolve@^1.10.0, resolve@^1.10.1, resolve@^1.20.0:
-  version "1.20.0"
-  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975"
-  integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==
+resolve@^1.10.0, resolve@^1.10.1, resolve@^1.19.0, resolve@^1.22.4:
+  version "1.22.6"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.6.tgz#dd209739eca3aef739c626fea1b4f3c506195362"
+  integrity sha512-njhxM7mV12JfufShqGy3Rz8j11RPdLy4xi15UurGJeoHLfJpVXKdh3ueuOqbYUcDZnffr6X739JBo5LzyahEsw==
   dependencies:
-    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"
+    is-core-module "^2.13.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"
-  integrity sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=
-  dependencies:
-    lowercase-keys "^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"
@@ -4021,17 +3725,10 @@
   dependencies:
     glob "^7.1.3"
 
-rollup@^2.45.2:
-  version "2.56.3"
-  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.56.3.tgz#b63edadd9851b0d618a6d0e6af8201955a77aeff"
-  integrity sha512-Au92NuznFklgQCUcV96iXlxUbHuB1vQMaH76DHl5M11TotjOHwqk9CwcrT78+Tnv4FN9uTBxq6p4EJoYkpyekg==
-  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==
+rollup@^2.67.0, rollup@^2.79.1:
+  version "2.79.1"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7"
+  integrity sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==
   optionalDependencies:
     fsevents "~2.3.2"
 
@@ -4054,15 +3751,34 @@
   dependencies:
     tslib "^1.9.0"
 
+safe-array-concat@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.0.1.tgz#91686a63ce3adbea14d61b14c99572a8ff84754c"
+  integrity sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==
+  dependencies:
+    call-bind "^1.0.2"
+    get-intrinsic "^1.2.1"
+    has-symbols "^1.0.3"
+    isarray "^2.0.5"
+
 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==
 
+safe-regex-test@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.0.tgz#793b874d524eb3640d1873aad03596db2d4f2295"
+  integrity sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==
+  dependencies:
+    call-bind "^1.0.2"
+    get-intrinsic "^1.1.3"
+    is-regex "^1.1.4"
+
 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=
+  integrity sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg==
   dependencies:
     ret "~0.1.10"
 
@@ -4071,53 +3787,41 @@
   resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
   integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
 
-semver-diff@^3.1.1:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-3.1.1.tgz#05f77ce59f325e00e2706afd67bb506ddb1ca32b"
-  integrity sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg==
-  dependencies:
-    semver "^6.3.0"
-
 "semver@2 || 3 || 4 || 5", semver@^5.5.0:
-  version "5.7.1"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
-  integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
+  version "5.7.2"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8"
+  integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==
 
 semver@5.6.0:
   version "5.6.0"
   resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004"
   integrity sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==
 
-semver@^6.0.0, semver@^6.1.0, semver@^6.2.0, semver@^6.3.0:
-  version "6.3.0"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
-  integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
+semver@^6.1.0, semver@^6.3.1:
+  version "6.3.1"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
+  integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
 
-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==
+semver@^7.3.4, semver@^7.3.7, semver@^7.5.1:
+  version "7.5.4"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e"
+  integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==
   dependencies:
     lru-cache "^6.0.0"
 
 set-blocking@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
-  integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
+  integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==
+
+set-function-name@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.1.tgz#12ce38b7954310b9f61faa12701620a0c882793a"
+  integrity sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==
+  dependencies:
+    define-data-property "^1.0.1"
+    functions-have-names "^1.2.3"
+    has-property-descriptors "^1.0.0"
 
 set-value@^2.0.0, set-value@^2.0.1:
   version "2.0.1"
@@ -4142,7 +3846,7 @@
 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=
+  integrity sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==
   dependencies:
     shebang-regex "^1.0.0"
 
@@ -4156,7 +3860,7 @@
 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=
+  integrity sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==
 
 shebang-regex@^3.0.0:
   version "3.0.0"
@@ -4164,9 +3868,9 @@
   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==
+  version "1.8.1"
+  resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.1.tgz#6dbf4db75515ad5bac63b4f1894c3a154c766680"
+  integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==
 
 side-channel@^1.0.4:
   version "1.0.4"
@@ -4178,9 +3882,9 @@
     object-inspect "^1.9.0"
 
 signal-exit@^3.0.2, signal-exit@^3.0.3:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c"
-  integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==
+  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==
 
 slash@^3.0.0:
   version "3.0.0"
@@ -4236,10 +3940,10 @@
     buffer-from "^1.0.0"
     source-map "^0.6.0"
 
-source-map-support@~0.5.19:
-  version "0.5.19"
-  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
-  integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==
+source-map-support@~0.5.20:
+  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"
@@ -4252,7 +3956,7 @@
 source-map@^0.5.6:
   version "0.5.7"
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
-  integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=
+  integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==
 
 source-map@^0.6.0:
   version "0.6.1"
@@ -4260,14 +3964,14 @@
   integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
 
 source-map@~0.7.2:
-  version "0.7.3"
-  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383"
-  integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==
+  version "0.7.4"
+  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656"
+  integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==
 
 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==
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.2.0.tgz#4f5ab0668f0059e34f9c00dce331784a12de4e9c"
+  integrity sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==
   dependencies:
     spdx-expression-parse "^3.0.0"
     spdx-license-ids "^3.0.0"
@@ -4286,9 +3990,9 @@
     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.15"
+  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.15.tgz#142460aabaca062bc7cd4cc87b7d50725ed6a4ba"
+  integrity sha512-lpT8hSQp9jAKp9mhtBU4Xjon8LPGBvLIuBiSVhMEtmLecTh2mO0tlqrAMp47tBXzMr13NJMQ2lf7RpQGLJ3HsQ==
 
 split-string@^3.0.1, split-string@^3.0.2:
   version "3.1.0"
@@ -4300,7 +4004,7 @@
 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=
+  integrity sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==
   dependencies:
     define-property "^0.2.5"
     object-copy "^0.1.0"
@@ -4310,82 +4014,57 @@
   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"
-  integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==
-  dependencies:
-    emoji-regex "^7.0.1"
-    is-fullwidth-code-point "^2.0.0"
-    strip-ansi "^5.1.0"
+stream-read-all@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/stream-read-all/-/stream-read-all-3.0.1.tgz#60762ae45e61d93ba0978cda7f3913790052ad96"
+  integrity sha512-EWZT9XOceBPlVJRrYcykW8jyRSZYbkb/0ZK36uLEmoWVO5gxBOnntNTseNzfREsqxqdfEGQrD8SXQ3QWbBmq8A==
 
-string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0:
-  version "4.2.2"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5"
-  integrity sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==
+string-width@^4.1.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.0"
+    strip-ansi "^6.0.1"
 
 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==
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/string.prototype.padend/-/string.prototype.padend-3.1.5.tgz#311ef3a4e3c557dd999cdf88fbdde223f2ac0f95"
+  integrity sha512-DOB27b/2UTTD+4myKUFh+/fXWcu/UDyASIXfg+7VzoCNNGOfWvoyU/x5pvVHr++ztyt/oSYI1BcWBBG/hmlNjA==
   dependencies:
     call-bind "^1.0.2"
-    define-properties "^1.1.3"
-    es-abstract "^1.19.1"
+    define-properties "^1.2.0"
+    es-abstract "^1.22.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"
-  integrity sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==
+string.prototype.trim@^1.2.8:
+  version "1.2.8"
+  resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz#f9ac6f8af4bd55ddfa8895e6aea92a96395393bd"
+  integrity sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==
   dependencies:
     call-bind "^1.0.2"
-    define-properties "^1.1.3"
+    define-properties "^1.2.0"
+    es-abstract "^1.22.1"
 
-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==
+string.prototype.trimend@^1.0.7:
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz#1bb3afc5008661d73e2dc015cd4853732d6c471e"
+  integrity sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==
   dependencies:
     call-bind "^1.0.2"
-    define-properties "^1.1.4"
-    es-abstract "^1.19.5"
+    define-properties "^1.2.0"
+    es-abstract "^1.22.1"
 
-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"
-  integrity sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==
+string.prototype.trimstart@^1.0.7:
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz#d4cdb44b83a4737ffbac2d406e405d43d0184298"
+  integrity sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==
   dependencies:
     call-bind "^1.0.2"
-    define-properties "^1.1.3"
+    define-properties "^1.2.0"
+    es-abstract "^1.22.1"
 
-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:
-    call-bind "^1.0.2"
-    define-properties "^1.1.4"
-    es-abstract "^1.19.5"
-
-strip-ansi@^5.1.0:
-  version "5.2.0"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae"
-  integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==
-  dependencies:
-    ansi-regex "^4.1.0"
-
-strip-ansi@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532"
-  integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==
-  dependencies:
-    ansi-regex "^5.0.0"
-
-strip-ansi@^6.0.1:
+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==
@@ -4395,7 +4074,7 @@
 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-final-newline@^2.0.0:
   version "2.0.0"
@@ -4409,16 +4088,11 @@
   dependencies:
     min-indent "^1.0.0"
 
-strip-json-comments@^3.1.0, strip-json-comments@^3.1.1:
+strip-json-comments@^3.1.1:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
   integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
 
-strip-json-comments@~2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
-  integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo=
-
 supports-color@^5.3.0:
   version "5.5.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
@@ -4438,34 +4112,37 @@
   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==
+table-layout@^3.0.0:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-3.0.2.tgz#69c2be44388a5139b48c59cf21e73b488021769a"
+  integrity sha512-rpyNZYRw+/C+dYkcQ3Pr+rLxW4CfHpXjPDnG7lYhdRoUcZTUt+KEsX+94RGp/aVp/MQU35JCITv2T/beY4m+hw==
   dependencies:
-    array-back "^4.0.1"
-    deep-extend "~0.6.0"
-    typical "^5.2.0"
-    wordwrapjs "^4.0.0"
+    "@75lb/deep-merge" "^1.1.1"
+    array-back "^6.2.2"
+    command-line-args "^5.2.1"
+    command-line-usage "^7.0.0"
+    stream-read-all "^3.0.1"
+    typical "^7.1.1"
+    wordwrapjs "^5.1.0"
 
-terser@^5.6.1:
-  version "5.7.2"
-  resolved "https://registry.yarnpkg.com/terser/-/terser-5.7.2.tgz#d4d95ed4f8bf735cb933e802f2a1829abf545e3f"
-  integrity sha512-0Omye+RD4X7X69O0eql3lC4Heh/5iLj3ggxR/B5ketZLOtLiOqukUgjw3q4PDnNQbsrkKr3UMypqStQG3XKRvw==
+terser@~5.8.0:
+  version "5.8.0"
+  resolved "https://registry.yarnpkg.com/terser/-/terser-5.8.0.tgz#c6d352f91aed85cc6171ccb5e84655b77521d947"
+  integrity sha512-f0JH+6yMpneYcRJN314lZrSwu9eKkUFEHLN/kNy8ceh8gaRiLgFPJqrB9HsXjhEGdv4e/ekjTOFxIlL6xlma8A==
   dependencies:
     commander "^2.20.0"
     source-map "~0.7.2"
-    source-map-support "~0.5.19"
+    source-map-support "~0.5.20"
 
 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=
+  integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==
 
 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=
+  integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==
 
 tmp@^0.0.33:
   version "0.0.33"
@@ -4477,19 +4154,14 @@
 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=
+  integrity sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg==
   dependencies:
     kind-of "^3.0.2"
 
-to-readable-stream@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/to-readable-stream/-/to-readable-stream-1.0.0.tgz#ce0aa0c2f3df6adf852efb404a783e77c0475771"
-  integrity sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==
-
 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=
+  integrity sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==
   dependencies:
     is-number "^3.0.0"
     repeat-string "^1.6.1"
@@ -4540,13 +4212,13 @@
   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.14.1:
-  version "3.14.1"
-  resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz#ba0734599e8ea36c862798e920bcf163277b137a"
-  integrity sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==
+tsconfig-paths@^3.14.2:
+  version "3.14.2"
+  resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz#6e32f1f79412decd261f92d633a9dc1cfa99f088"
+  integrity sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==
   dependencies:
     "@types/json5" "^0.0.29"
-    json5 "^1.0.1"
+    json5 "^1.0.2"
     minimist "^1.2.6"
     strip-bom "^3.0.0"
 
@@ -4607,6 +4279,45 @@
     media-typer "0.3.0"
     mime-types "~2.1.24"
 
+typed-array-buffer@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz#18de3e7ed7974b0a729d3feecb94338d1472cd60"
+  integrity sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==
+  dependencies:
+    call-bind "^1.0.2"
+    get-intrinsic "^1.2.1"
+    is-typed-array "^1.1.10"
+
+typed-array-byte-length@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz#d787a24a995711611fb2b87a4052799517b230d0"
+  integrity sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==
+  dependencies:
+    call-bind "^1.0.2"
+    for-each "^0.3.3"
+    has-proto "^1.0.1"
+    is-typed-array "^1.1.10"
+
+typed-array-byte-offset@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz#cbbe89b51fdef9cd6aaf07ad4707340abbc4ea0b"
+  integrity sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==
+  dependencies:
+    available-typed-arrays "^1.0.5"
+    call-bind "^1.0.2"
+    for-each "^0.3.3"
+    has-proto "^1.0.1"
+    is-typed-array "^1.1.10"
+
+typed-array-length@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.4.tgz#89d83785e5c4098bec72e08b319651f0eac9c1bb"
+  integrity sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==
+  dependencies:
+    call-bind "^1.0.2"
+    for-each "^0.3.3"
+    is-typed-array "^1.1.9"
+
 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"
@@ -4619,35 +4330,25 @@
   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==
+typescript@^4.9.5:
+  version "4.9.5"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
+  integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
 
 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==
+typical@^7.1.1:
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/typical/-/typical-7.1.1.tgz#ba177ab7ab103b78534463ffa4c0c9754523ac1f"
+  integrity sha512-T+tKVNs6Wu7IWiAce5BgMd7OZfNYUndHwc5MknN+UHOudi7sGZzuHdCadllRuqJ3fPtgFtIH9+lt9qRv6lmpfA==
 
-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"
-  integrity sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==
-  dependencies:
-    function-bind "^1.1.1"
-    has-bigints "^1.0.1"
-    has-symbols "^1.0.2"
-    which-boxed-primitive "^1.0.2"
+ua-parser-js@^1.0.33:
+  version "1.0.36"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.36.tgz#a9ab6b9bd3a8efb90bb0816674b412717b7c428c"
+  integrity sha512-znuyCIXzl8ciS3+y3fHJI/2OhQIXbXw9MWC/o3qwyR+RGppjZHrM27CGFSKCJXi2Kctiz537iOu2KnXs1lMQhw==
 
 unbox-primitive@^1.0.2:
   version "1.0.2"
@@ -4669,41 +4370,14 @@
     is-extendable "^0.1.1"
     set-value "^2.0.1"
 
-unique-string@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d"
-  integrity sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==
-  dependencies:
-    crypto-random-string "^2.0.0"
-
 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=
+  integrity sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ==
   dependencies:
     has-value "^0.3.1"
     isobject "^3.0.0"
 
-update-notifier@^5.0.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-5.1.0.tgz#4ab0d7c7f36a231dd7316cf7729313f0214d9ad9"
-  integrity sha512-ItnICHbeMh9GqUy31hFPrD1kcuZ3rpxDZbf4KUDavXwS0bW5m7SLbDQpGX3UYr072cbrF5hFUs3r5tUsPwjfHw==
-  dependencies:
-    boxen "^5.0.0"
-    chalk "^4.1.0"
-    configstore "^5.0.1"
-    has-yarn "^2.1.0"
-    import-lazy "^2.1.0"
-    is-ci "^2.0.0"
-    is-installed-globally "^0.4.0"
-    is-npm "^5.0.0"
-    is-yarn-global "^0.3.0"
-    latest-version "^5.1.0"
-    pupa "^2.1.1"
-    semver "^7.3.4"
-    semver-diff "^3.1.1"
-    xdg-basedir "^4.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"
@@ -4714,25 +4388,13 @@
 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@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-3.0.0.tgz#16b5cafc07dbe3676c1b1999177823d6503acb0c"
-  integrity sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=
-  dependencies:
-    prepend-http "^2.0.0"
+  integrity sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==
 
 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==
 
-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"
-  integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==
-
 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"
@@ -4767,9 +4429,9 @@
     vscode-uri "^2.1.2"
 
 vscode-languageserver-textdocument@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.1.tgz#178168e87efad6171b372add1dea34f53e5d330f"
-  integrity sha512-UIcJDjX7IFkck7cSkNNyzIz5FyvpQfY7sdzVy+wkKN/BLaD4DQ0ppXQrKePomCxTS7RrolK1I0pey0bG9eh8dA==
+  version "1.0.8"
+  resolved "https://registry.yarnpkg.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.8.tgz#9eae94509cbd945ea44bca8dcfe4bb0c15bb3ac0"
+  integrity sha512-1bonkGqQs5/fxGT5UchTgjGVnfysL0O8v1AYMBjqTbWQTFn721zaPGDYFkOKtfDgFiSgXM3KwaG3FMGfW4Ed9Q==
 
 vscode-languageserver-types@3.16.0-next.2:
   version "3.16.0-next.2"
@@ -4787,9 +4449,9 @@
   integrity sha512-8TEXQxlldWAuIODdukIb+TR5s+9Ds40eSJrw+1iDDA9IFORPjMELarNQE3myz5XIkWWpdprmJjm1/SxMlWOC8A==
 
 web-component-analyzer@~1.1.1:
-  version "1.1.6"
-  resolved "https://registry.yarnpkg.com/web-component-analyzer/-/web-component-analyzer-1.1.6.tgz#d9bd904d904a711c19ba6046a45b60a7ee3ed2e9"
-  integrity sha512-1PyBkb/jijDEVE+Pnk3DTmVHD8takipdvAwvZv1V8jIidsSIJ5nhN87Gs+4dpEb1vw48yp8dnbZKkvMYJ+C0VQ==
+  version "1.1.7"
+  resolved "https://registry.yarnpkg.com/web-component-analyzer/-/web-component-analyzer-1.1.7.tgz#dad7f5a91f3b095c5a54f0f48d2dccb810c96b89"
+  integrity sha512-SqCqN4nU9fU+j0CKXJQ8E4cslLsaezhagY6xoi+hoNPPd55GzR6MY1r5jkoJUVu+g4Wy4uB+JglTt7au4vQ1uA==
   dependencies:
     fast-glob "^3.2.2"
     ts-simple-type "~1.0.5"
@@ -4821,9 +4483,20 @@
     is-symbol "^1.0.3"
 
 which-module@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
-  integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409"
+  integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==
+
+which-typed-array@^1.1.11:
+  version "1.1.11"
+  resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.11.tgz#99d691f23c72aab6768680805a271b69761ed61a"
+  integrity sha512-qe9UWWpkeG5yzZ0tNYxDmd7vo58HDBc39mZ0xWWpolAGADdFOzkfamWLDxkOWcvHQKVmdTyQdLD4NOfjLWTKew==
+  dependencies:
+    available-typed-arrays "^1.0.5"
+    call-bind "^1.0.2"
+    for-each "^0.3.3"
+    gopd "^1.0.1"
+    has-tostringtag "^1.0.0"
 
 which@^1.2.9:
   version "1.3.1"
@@ -4839,25 +4512,10 @@
   dependencies:
     isexe "^2.0.0"
 
-widest-line@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca"
-  integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==
-  dependencies:
-    string-width "^4.0.0"
-
-word-wrap@^1.2.3:
-  version "1.2.3"
-  resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
-  integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
-
-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"
+wordwrapjs@^5.1.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-5.1.0.tgz#4c4d20446dcc670b14fa115ef4f8fd9947af2b3a"
+  integrity sha512-JNjcULU2e4KJwUNv6CHgI46UvDGitb6dGryHajXTDiLgg1/RiGoPSDw4kZfYnwGtEXf2ZMeIewDQgFGzkCB2Sg==
 
 wrap-ansi@^6.2.0:
   version "6.2.0"
@@ -4868,21 +4526,12 @@
     string-width "^4.1.0"
     strip-ansi "^6.0.0"
 
-wrap-ansi@^7.0.0:
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
-  integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
-  dependencies:
-    ansi-styles "^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 sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
+  integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
 
-write-file-atomic@^3.0.0, write-file-atomic@^3.0.3:
+write-file-atomic@^3.0.3:
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8"
   integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==
@@ -4897,11 +4546,6 @@
   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"
-  integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==
-
 y18n@^4.0.0:
   version "4.0.3"
   resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf"
@@ -4946,3 +4590,8 @@
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.3.2.tgz#0de48017473275a4cbdfc83a1eaf67c01af8a785"
   integrity sha512-RXRJzMiK6U2ye0BlGGZnmpwJDPgakn6aNQ0A7gHRbD4I0uvK4TW6UqkK1V0pp9jskjJBAXd3dRrbzWkqJ+6cxA==
+
+yocto-queue@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
+  integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==